Skip to content

Atom global state

Atom is for process-wide shared state: theme, logged-in user, cross-page cache, and global selections. This example puts “committed state” into atoms and keeps “draft input” in page-local use_state, so global state is not polluted by every keypress.

atom state demo

This recording is generated by docs/tapes/atom-state.tape. The example enters the focus editor and types write atom docs; after submission, dashboard subscribers immediately show the new value. It then enters the score editor, changes the score, and another component reads the same Atom back on the dashboard.

cargo run --example atom_state

On the dashboard, press 1 to edit focus, 2 to edit score, and q to exit. In the focus editor, type text and press Enter to submit. In the score editor, press +/-/r to change the score and Esc to go back.

Atom stores committed shared state; input drafts remain in the editor page's own use_state.

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

static FOCUS: Atom<String> = Atom::new(|| "Review runtime".to_string());
static SCORE: Atom<i32> = Atom::new(|| 2);
static LAST_EVENT: Atom<String> = Atom::new(|| "dashboard initialized".to_string());

Atom::new receives an initializer function. The real state is created lazily on the first read or use_atom, then lives for the process lifetime.

Use use_atom inside a component:

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

    element!(
        Border(top_title: Line::from(" shared focus ").centered()) {
            Text(text: Line::from(focus.read().to_string()).centered())
        }
    )
}

use_atom(&FOCUS) returns AtomState<T>. Like State<T>, it is a copyable reactive handle: read with get() or read(), write with set(), write(), or operators such as +=.

FocusCard, ScoreCard, and EventCard are independent components. Each subscribes only to the Atom it needs; writing SCORE does not require putting focus text into the same large structure.

The focus editor has a single-line input. Characters typed during editing are local 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);

This boundary matters. A draft belongs to the current editor page and should disappear when the page unmounts. The submitted focus belongs to the whole application. Writing every keypress to an Atom would wake unrelated components frequently and make “cancel edit” awkward.

The example uses RouterProvider to switch between three pages, but Router history itself is not stored in an Atom:

let routes = routes! {
    "/" => Dashboard,
    "/focus" => FocusEditor,
    "/score" => ScoreEditor,
};

Router state is provider-scoped and belongs inside RouterProvider. Atom is process-wide state for business data that multiple pages need to read and write. When one navigation needs a temporary payload, use push_with_state / replace_with_state; do not emulate route state with a global Atom.

Prefer the smallest scope that works:

StateWhere to put it
Input draftuse_state
List cursorComponent-internal state or external State<ListState>
Current logged-in userAtom<User>
Cross-page cacheAtom<Cache>
Router historyState inside RouterProvider

Next, read Input isolation to see how terminal keyboard events are routed by layer beyond shared state.