Skip to content

State

State in Ratatui Kit does not mean “put everything in one store.” Different state has different lifetimes: some follows a component instance, some belongs only to a Provider subtree, some follows one navigation, and only some is process-wide shared state.

To see a complete running example first, start with Atom global state. This page is the reference: it explains where each kind of state belongs and how reactive handles such as State<T> and AtomState<T> work.

StatePreferLifetime
Input draft, list cursor, modal open flaguse_stateFollows component instance
Subtree theme, workspace, local service objectContextProvider + use_stateFollows Provider subtree
Temporary context carried by one navigationRouteStateFollows history entry
Current user, global theme, cross-page cacheAtom / use_atomFollows process
The farther right state moves, the longer it lives and the wider its impact; start with the smallest scope.

The more global state becomes, the more carefully it should be used. Atom is not “stronger State”; it is State with a longer lifetime and a wider scope.

use_state creates component-local reactive state:

let mut count = hooks.use_state(|| 0i32);
count += 1;

The returned State<T> is a copyable handle. You can pass it by value into event closures, child component props, hand-written components, or async tasks:

let mut draft = hooks.use_state(String::new);

hooks.use_event_handler(EventScope::Current, EventPriority::Normal, move |event| {
    if let Event::Key(key) = event
        && let KeyCode::Char(ch) = key.code
    {
        draft.write().push(ch);
        return EventResult::Consumed;
    }

    EventResult::Ignored
});

State<T> requires T: Unpin + Send + Sync + 'static. That constraint lets state handles safely move into background tasks and wake the UI after the task completes.

State<T> and AtomState<T> are both based on the same ReactiveHandle, so the read/write APIs are consistent:

APIPurposeFailure behavior
get()Read a Copy valuePanic on borrow conflict
read()Get an immutable borrowPanic on borrow conflict
try_read()Try immutable borrowReturn None
write()Get mutable borrow and wake after changePanic on borrow conflict
try_write()Try mutable borrowReturn None
set(value)Replace value and wakeSilently skip on borrow conflict
write_no_update()Mutable borrow without wakePanic on borrow conflict
try_write_no_update()Try mutable borrow without wakeReturn None
set_no_update(value)Replace value without wakeSilently skip on borrow conflict

Assertion-style APIs fit normal component logic: borrow conflicts are usually programming errors and should surface quickly. try_* APIs fit optional capabilities, defensive reads inside callbacks, or code that explicitly knows a value may be borrowed.

Writes wake the render loop when the mutable borrow drops:

{
    let mut items = list.write();
    items.push("new item".to_string());
} // drop here, notify UI to update next frame

For Copy numbers, +=, -=, *=, and /= trigger the same change notification:

let mut score = hooks.use_state(|| 0i32);
score += 1;

One kind of write should not trigger a UI update: draw-stage state passed to a Ratatui stateful widget.

drawer.render_stateful_widget(
    list,
    drawer.area,
    &mut self.state.write_no_update(),
);

This write exists because Ratatui’s drawing protocol needs &mut State; it is not a business state change. The real selected-item change should happen in an event handler with write(). Native widget bridge shows this boundary.

try_write_no_update() and set_no_update() are non-panicking / direct-assignment variants on the same path. They fit drawing protocols, test harnesses, or very explicit internal synchronization. Ordinary business state writes should keep using write() / set() so the interface can wake.

Atom is feature-gated; enable atom or full. It is for process-wide shared state: theme, current user, cross-page cache, and global selection.

Atoms are module-level declarations and do not need a macro:

static THEME: Atom<String> = Atom::new(|| "dark".to_string());

Subscribe inside a component with use_atom:

#[component]
fn ThemeLabel(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
    let theme = hooks.use_atom(&THEME);

    element!(Text(text: format!("theme: {}", theme.read().as_str())))
}

use_atom(&THEME) returns AtomState<T>. Like State<T>, it can be read, written, copied, and moved into closures. The difference is the wake model: writing an Atom wakes components subscribed to that Atom; components that did not subscribe are not redrawn for that write.

You can also read and write an Atom outside components:

THEME.set("light".to_string());
let current = THEME.state().read().clone();

get() is only for T: Copy; for non-Copy values, use THEME.state().read().

Every frame, use_atom calibrates its internal handle to the Atom passed at that hook position. When a component unmounts, or when the same hook position starts subscribing to a different Atom, the waker registered on the old Atom under that component key is removed.

That means this “parameter decides which Atom to subscribe to” pattern is safe, as long as hook call order remains stable:

let focus = hooks.use_atom(if compact {
    &COMPACT_FOCUS
} else {
    &FULL_FOCUS
});

Do not maintain Atom subscription tables by hand; use_atom handles registration and unsubscription.

Characters typed during input usually should not be written to an Atom. In Atom global state:

let draft = hooks.use_state(tui_input::Input::default);
let mut focus = hooks.use_atom(&FOCUS);

// typing
draft.write().handle_event(&event);

// Enter
let value = draft.read().value().trim().to_string();
focus.set(value);

The draft belongs to the current editor page; the submitted value belongs to the whole app. Writing every keypress into Atom wakes unrelated subscribers frequently and makes canceling an edit awkward.

If data should be visible only inside a subtree, prefer ContextProvider. Common examples include local themes, workspace settings, and page-level service objects.

let settings = WorkspaceSettings { /* ... */ };

element!(
    ContextProvider(value: Context::owned(settings)) {
        WorkspaceSummary
        ScopedPanel
    }
)

Context’s value is clear scope and local override. The same type can be overridden in nested Providers, and a child component reads the nearest layer. See Custom provider for the full example.

Do not promote page-level state to Atom just because it needs to travel far. First ask whether it has a natural scope; if it does, Provider is usually clearer.

Router is provider-scoped state, not a global singleton. RouteState is an optional payload on one history entry, useful for carrying one-shot context when moving from a list page to a detail page:

navigate.push_with_state(
    "/projects/boreal",
    RouteNotice { message },
);

Plain push(path) / replace(path) clears current route state; only push_with_state / replace_with_state carries state explicitly. This prevents old detail-page payloads from leaking into unrelated pages.

Information required by the URL belongs in path params. One-shot context without URL semantics belongs in RouteState. See Routing for details.

QuestionLean toward
Should state disappear when the component unmounts?use_state
Does it belong only to one page or subtree?ContextProvider + use_state
Does it belong only to one navigation?RouteState
Do multiple pages need to read and write the same long-lived business data?Atom
Should a write trigger this component to redraw?write() / set()
Is this only to satisfy a stateful widget protocol during draw?write_no_update()

A practical rule: place state in the smallest scope where it works correctly. Promote it to Atom only when multiple independent pages, components, or background tasks need the same long-lived state.