Skip to content

Render Loop

This page is for framework contributors and people debugging runtime behavior. For everyday app development, Component Model, Hooks, and Input Layers are enough. Read this full call chain only when you are changing render/, component/, hooks/, or input/.

Ratatui Kit’s runtime is not “each component redraws the terminal on its own”. There is one root loop: first run update on the component tree, then draw the tree into the ratatui buffer, then wait for either component-tree changes or terminal events.

A loop contains update, draw, exit check, waiting for change or event; after event dispatch it always returns to the top.

Tree::render() does only two things:

  1. update_once(terminal): runs component update top-down, executes hooks, reconciles the subtree, and registers this frame’s input layers and event handlers again.
  2. terminal.draw(...): runs draw top-down and writes component content and child areas into the ratatui frame.

These two stages are intentionally separate. update can read props, run hooks, register input, and work with context; draw only draws the current component into the provided ComponentDrawer. Do not create state in draw that needs to participate in next-frame scheduling, and do not dispatch events while SystemContext is borrowed during update.

Source entry points:

crates/ratatui-kit/src/render/tree.rs
Tree::render_loop
Tree::render
Tree::update_once
Tree::draw_root

App entry starts from ElementExt:

element!(App).fullscreen().await?;
element!(App).render_loop(options).await?;

fullscreen() enters ratatui fullscreen mode with the default CrossTerminal::new(). render_loop(options) uses CrossTerminal::with_options(options), which fits custom TerminalOptions. Both eventually create Terminal<CrossTerminal> and enter the same render_loop.

The lower layer has three parts:

TypeResponsibility
CrossTerminalDefault crossterm + ratatui backend; owns ratatui::init / init_with_options, draw, and raw event stream
Terminal<T>Holds any TerminalImpl and exposes draw, insert_before, and next_event to the runtime
TerminalImplTrait for custom terminal backends: event stream, Ctrl+C detection, draw, and leading insert area

Terminal no longer stores long-lived event subscribers. It is only the raw event source: next_event() yields one event, the runtime checks TerminalImpl::received_ctrl_c(event), then passes it to InputRuntime::dispatch(event).

During update, components do not receive the full Terminal<T>, but an object-safe UpdaterTerminal projection. The component tree is dispatched through trait objects, so ComponentUpdater cannot carry a concrete generic terminal type. Also, the update stage only needs one terminal ability: insert_before. Ordinary components should use use_insert_before() for this instead of depending directly on a terminal implementation.

At the beginning of every frame, update calls:

self.system_context.input.begin_frame();

This clears the previous frame’s input layers and handlers and mints the root layer. Only after that does it create:

let mut component_context_stack = ContextStack::root(&mut self.system_context);

The order matters: ContextStack::root borrows &mut SystemContext, so the input table must be rebuilt before that borrow starts. Component functions, hooks, and handwritten Component::update calls can register frame resources only inside this context scope.

InstantiatedComponent::update runs in this order:

  1. Create ComponentUpdater, which holds the current key, context stack, terminal projection, child component table, and layout style.
  2. Run hooks’ pre_component_update.
  3. Call the component’s own update or the function component body generated by the macro.
  4. Run hooks’ post_component_update.
  5. Record whether this frame uses transparent layout.
  6. If it is transparent layout, inherit the first child component’s LayoutStyle; if there are no children, reset to the default layout.

Function components are transparent layout by default. In other words, a function component itself is more like a logic wrapper; the root child component it returns is what actually participates in parent layout.

ComponentUpdater::update_children is where Element becomes persistent Component:

incoming Element

same ElementKey + same component TypeId ?
        ↓ yes                         ↓ no
reuse old InstantiatedComponent       create new InstantiatedComponent

update child with new props

This explains why lists need stable keys. Elements are rebuilt every frame, but InstantiatedComponents are reused when key and type both match, preserving hooks, local state, and subtrees. If the key is the same but the component type differs, the old instance is discarded and a new one is created.

For usage-level explanation, see Component Model and Control Flow.

InstantiatedComponent::draw draws the current component and its children into regions:

  1. If it is not transparent layout, apply margin and offset to the current region.
  2. Run hooks’ pre_component_draw.
  3. Call the component’s own draw.
  4. Call calc_children_areas to compute each child region.
  5. Write each region into the drawer and recursively draw children.
  6. Run hooks’ post_component_draw.

calc_children_areas must return the same number of Rects as there are children. The runtime asserts this in debug mode:

debug_assert_eq!(
    children_areas.len(),
    self.children.components.iter().count(),
    "calc_children_areas must return one area per child"
);

This is the contract most easily broken by custom layout components. ScrollView, Modal, and custom widget components can override layout, but they must not return a region count that differs from the child count.

After draw completes, the loop checks SystemContext::should_exit(). If it should not exit, it waits for two kinds of signals:

select(
    self.root_component.wait().boxed_local(),
    terminal.next_event().boxed_local(),
)

root_component.wait() ultimately polls the whole component tree for changes. InstantiatedComponent::poll_change aggregates three sources:

SourceExamplePurpose
Component itselfHandwritten Component::poll_changeCustom async or external resources
ChildrenAny state change in the subtreePropagate redraw demand upward
Hooksuse_state, use_future, use_async_stateReactive state and tasks stored by hooks

This must not be written as a short-circuit check. Even if the component itself is already Ready, the Pending branches for children and hooks must still register their wakers in the same poll. Otherwise later changes may have no target to wake, and the UI will refresh only after some unrelated event.

Terminal events come from terminal.next_event(). After receiving an event, the runtime checks Ctrl+C first:

if CrossTerminal::received_ctrl_c(event.clone()) {
    break;
}

Ctrl+C is a process-level exit signal before input layers, so no handler’s Consumed can swallow it.

Ordinary events enter:

self.system_context.input.dispatch(event);
continue;

Dispatch happens only after render() has fully returned. At that point, ContextStack has been dropped and component update/draw borrows have ended, so handlers can write State, AtomState, or call an exit function captured from use_exit without re-entering the update borrow.

After dispatch, the loop must unconditionally continue to the top. Some handlers only perform side effects or set exit state and may not write reactive state that wakes a waker. If dispatch continued to block in select, those events might never trigger the next render and exit check.

The input system is not a set of subscribers hanging on the terminal event stream forever. It is rebuilt every frame:

  1. begin_frame() clears the previous frame’s layers and handlers.
  2. During component update, use_input_layer and use_event_handler register again.
  3. After render completes, dispatch sees only the candidates registered for the current frame.
  4. The next frame starts over.

This design solves two problems:

  • Closed modals, unmounted components, and handlers in conditional branches disappear naturally on the next frame without manual unsubscribe.
  • Input exclusivity is decided by the active layer stack instead of degrading into “every component broadcasts the same key”.

See Input Layers for dispatch order. Implementation entry points:

crates/ratatui-kit/src/input/mod.rs
InputRuntime::begin_frame
InputRuntime::dispatch
crates/ratatui-kit/src/hooks/use_input.rs
InvariantWhat breaks if violated
begin_frame must run before ContextStack::rootMutable borrow conflict on SystemContext; input table cannot be rebuilt per frame
dispatch must happen after update/drawHandler state writes can re-enter context borrows
dispatch must return to the loop topExit handlers or side-effect-only handlers can stall
poll_change must poll component, subtree, and hooksWaker registration is lost and later changes do not refresh
calc_children_areas count must equal child countChild draw regions drift or are silently dropped
transparent layout with an empty subtree must reset layoutLayout inherited from the previous frame can pollute the next frame
ElementKey + TypeId are both required to reuse component instancesList state can cross rows, or state that should not be preserved is preserved

If you change runtime code and then see “state was written but UI did not wake”, “the background still responds behind a modal”, “list state jumped to another row”, or “custom layout skipped a child”, start by checking these invariants.