-
Notifications
You must be signed in to change notification settings - Fork 2
Component Communication Patterns #11
Description
Controlled vs uncontrolled components
Three patterns for parent-child data flow:
| Pattern | State lives in | Parent reads via | Good for |
|---|---|---|---|
| Controlled | Parent state | Props down, events up | Forms, validated inputs |
| Uncontrolled + ref | Child state | Imperative handle | Third-party widgets, complex internal state |
| Fire-and-forget | Child state | Events only | Buttons, toggles |
In React, controlled components are the default recommendation. In the TUI context, uncontrolled is often more natural — atuin's InputBox wraps tui-textarea behind a Mutex precisely because the widget owns its own state but the parent needs to read from it.
Imperative refs / handles
A component can expose a typed API via an imperative handle, letting the parent interact with child state without owning it:
// The child declares what it exposes
trait InputHandle {
fn text(&self) -> &str;
fn set_text(&self, text: &str);
fn cursor(&self) -> usize;
fn clear(&mut self);
}
// Parent gets a handle via hooks
#[component(props = FormProps, state = FormState, event = FormEvent)]
fn Form(props: &FormProps, state: &Tracked<FormState>, hooks: &mut Hooks) -> Elements {
let input_ref = hooks.use_ref::<InputHandle>();
hooks.use_event(|event, ctx| {
if is_enter(event) {
let text = ctx.ref_value(&input_ref).text().to_string();
ctx.emit(FormEvent::Submit(text));
ctx.ref_mut(&input_ref).clear();
}
EventResult::Consumed
});
element! {
Input(handle: input_ref, placeholder: "Type here...")
}
}
// The Input component exposes itself via the handle
#[component(props = InputProps, state = InputState)]
fn Input(props: &InputProps, state: &Tracked<InputState>, hooks: &mut Hooks) -> Elements {
hooks.use_imperative_handle(props.handle, |state| {
// return an impl of InputHandle backed by state
});
// ...
}This is especially valuable for wrapping external widgets (tui-textarea, tui-scrollview) where you don't want to mirror their entire state. The component exposes just the API surface the parent needs.
Implementation: Under the hood, the handle is a framework-managed slot (likely Arc<Mutex<>> or a typed channel). The API surface can be clean even though the plumbing is shared-state.
Relationship to other sections
- Typed events (section 8): Handles complement events. Events are for "something happened" (fire-and-forget). Handles are for "let me read/mutate your state" (imperative access).
- Function components (section 5):
use_refanduse_imperative_handleare natural hooks in the function model. - Context (existing): Context provides ambient data downward. Events bubble upward. Handles provide lateral access to specific children. Together they cover the full communication space.