Skip to content

Component model

Ratatui Kit’s component model has two layers: Element is the declaration recreated every frame, while Component is the persistent instance reused by the runtime. In application code, you mostly declare UI inside element!; during update, the framework reconciles those declarations into a component tree and preserves hooks, subtrees, layout, and lifecycle state.

If you come from React, this is similar to the relationship between JSX elements and component instances. The difference is that the output is a Ratatui buffer, not a DOM tree.

Elements are rebuilt every frame; Component instances are reused by reconciliation, and state is attached to instances and hooks.

A function component runs again every frame:

#[component]
fn Counter(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
    let count = hooks.use_state(|| 0);

    element!(
        Text(text: format!("count: {}", count.get()))
    )
}

The element!(Text(...)) call is a declaration. The thing that actually stores count is not this temporary declaration tree, but the instantiated Counter node in the runtime and its hook list.

You can think of Element<T> as:

Element<T> {
    key,
    props,
}

It is lightweight and can be rebuilt every frame. The element! macro turns declarative syntax into an element tree:

element!(
    Border(top_title: Line::from("dashboard").centered()) {
        Text(text: "ready")
        Counter
    }
)

AnyElement is the type-erased version that lets different component types live in the same children list. See Declarative syntax for the full macro syntax.

Component is the persistent object. An instance owns:

  • The component’s own state.
  • Its hook list.
  • Its child component tree.
  • Its layout style.
  • Lifecycle callbacks.

Function components get a generated Component implementation from #[component]. You write a function; the runtime sees a transparent wrapper component that runs the function body during each update, obtains the returned root element, and reconciles its subtree.

Hand-written components implement Component directly. This is usually for native Ratatui widget bridges or custom layout:

impl Component for DeployQueue {
    type Props<'a> = DeployQueueProps;

    fn update(
        &mut self,
        props: &mut Self::Props<'_>,
        _hooks: Hooks,
        _updater: &mut ComponentUpdater,
    ) {
        self.state = props.state;
    }

    fn draw(&mut self, drawer: &mut ComponentDrawer<'_, '_>) {
        drawer.render_stateful_widget(
            list,
            drawer.area,
            &mut self.state.write_no_update(),
        );
    }
}

See Native widget bridge for the complete example.

When reconciling children, the runtime matches current declarations to previous-frame nodes using ElementKey + TypeId:

Current declarationPrevious instanceResult
Same key, same component typeMatchReuse instance, preserve hooks and subtree
Same key, different component typeType changedRecreate instance
Key changedIdentity changedRecreate instance
Old key disappearedNo declarationUnmount instance and run on_drop

This is why list rendering needs stable keys:

for item in items {
    Row(key: item.id) {
        Text(text: item.title)
    }
}

Do not use array indices as keys when the list can insert, delete, or reorder items. After inserting one row, every following index changes, and old state may be reused on the wrong row.

Static small arrays can use indices. For example, ROWS in Control flow syntax is a fixed array, so key: index is stable.

Control flow belongs to the declaration layer

Section titled “Control flow belongs to the declaration layer”

element! supports if, if let, for, and match directly:

element!(
    View {
        if loading.get() {
            Text(text: "loading")
        } else {
            Border {
                Text(text: "ready")
            }
        }
    }
)

This control flow only changes the element declarations produced for the current frame. The runtime still uses key and type to decide which instances can be reused.

That explains an important boundary: you may use conditional rendering inside element!, but do not put hook calls inside conditional branches. Hook order belongs to the component instance and must be stable every frame; render branches belong to the declaration layer and may change every frame. See Hooks for more rules.

Props are the data passed to a component declaration. element! constructs the props struct from fields and fills missing fields with Default::default():

element!(
    SearchInput(
        value: query.read().clone(),
        placeholder: "search".to_string(),
        on_change: move |next| query.set(next),
    )
)

Custom props use #[derive(Props)]:

#[derive(Props)]
struct DeployQueueProps {
    state: State<ListState>,
}

If a props field cannot derive Default, write a semantically neutral impl Default. This is not cosmetic: element! depends on default values to fill omitted fields.

Optional fields should use Option<T>. The macro calls .into() on field values, so top_title: Line::from("title"), top_title: Some(line), and top_title: None all work.

Ratatui Kit layout fields come from LayoutStyle:

  • width
  • height
  • flex_direction
  • justify_content
  • gap
  • margin
  • offset

Built-in components expose these fields on props. Custom components that want to support layout props should add #[with_layout_style] to their props.

Function components have one special rule: the component generated by #[component] is transparent for layout. It does not occupy a separate layout node; it inherits the layout style of the first child element it returns.

So layout props should be written on the root element returned by the function component:

#[component]
fn Panel(_hooks: Hooks) -> impl Into<AnyElement<'static>> {
    element!(
        Border(width: Constraint::Length(40)) {
            Text(text: "content")
        }
    )
}

Do not expect Panel(width: ...) at the parent site to take effect unless Panel is a props-bearing hand-written component or explicitly forwards layout fields to its internal root.

The default component layout is flex-like child area splitting. Components can override calc_children_areas for non-flex behavior:

  • ScrollView combines content area with scroll offset.
  • Modal positions the surface in the screen center and injects an input layer into the subtree.
  • Custom canvases, tables, and split workspaces may need their own area calculation.

calc_children_areas must return exactly the same number of areas as there are children. Too few areas means a child will not draw; extra areas have no matching child. Debug builds try to expose these contract violations.