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.
What happens in one frame
Section titled “What happens in one frame”Tree::render() does only two things:
update_once(terminal): runs componentupdatetop-down, executes hooks, reconciles the subtree, and registers this frame’s input layers and event handlers again.terminal.draw(...): runsdrawtop-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:
Terminal abstraction
Section titled “Terminal abstraction”App entry starts from ElementExt:
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:
| Type | Responsibility |
|---|---|
CrossTerminal | Default 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 |
TerminalImpl | Trait 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.
Update stage
Section titled “Update stage”At the beginning of every frame, update calls:
This clears the previous frame’s input layers and handlers and mints the root layer. Only after that does it create:
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:
- Create
ComponentUpdater, which holds the current key, context stack, terminal projection, child component table, and layout style. - Run hooks’
pre_component_update. - Call the component’s own
updateor the function component body generated by the macro. - Run hooks’
post_component_update. - Record whether this frame uses transparent layout.
- 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.
Reconciling children
Section titled “Reconciling children”ComponentUpdater::update_children is where Element becomes persistent Component:
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.
Draw stage
Section titled “Draw stage”InstantiatedComponent::draw draws the current component and its children into regions:
- If it is not transparent layout, apply
marginandoffsetto the current region. - Run hooks’
pre_component_draw. - Call the component’s own
draw. - Call
calc_children_areasto compute each child region. - Write each region into the drawer and recursively draw children.
- 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:
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.
wait and waker
Section titled “wait and waker”After draw completes, the loop checks SystemContext::should_exit(). If it should not exit, it waits for two kinds of signals:
root_component.wait() ultimately polls the whole component tree for changes. InstantiatedComponent::poll_change aggregates three sources:
| Source | Example | Purpose |
|---|---|---|
| Component itself | Handwritten Component::poll_change | Custom async or external resources |
| Children | Any state change in the subtree | Propagate redraw demand upward |
| Hooks | use_state, use_future, use_async_state | Reactive 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.
Why input events dispatch after render
Section titled “Why input events dispatch after render”Terminal events come from terminal.next_event(). After receiving an event, the runtime checks Ctrl+C first:
Ctrl+C is a process-level exit signal before input layers, so no handler’s Consumed can swallow it.
Ordinary events enter:
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.
Rebuilding the input table every frame
Section titled “Rebuilding the input table every frame”The input system is not a set of subscribers hanging on the terminal event stream forever. It is rebuilt every frame:
begin_frame()clears the previous frame’s layers and handlers.- During component update,
use_input_layeranduse_event_handlerregister again. - After render completes,
dispatchsees only the candidates registered for the current frame. - 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:
Invariants for contributors
Section titled “Invariants for contributors”| Invariant | What breaks if violated |
|---|---|
begin_frame must run before ContextStack::root | Mutable borrow conflict on SystemContext; input table cannot be rebuilt per frame |
| dispatch must happen after update/draw | Handler state writes can re-enter context borrows |
| dispatch must return to the loop top | Exit handlers or side-effect-only handlers can stall |
poll_change must poll component, subtree, and hooks | Waker registration is lost and later changes do not refresh |
calc_children_areas count must equal child count | Child draw regions drift or are silently dropped |
| transparent layout with an empty subtree must reset layout | Layout inherited from the previous frame can pollute the next frame |
ElementKey + TypeId are both required to reuse component instances | List 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.