Skip to content

Todo App

todo_app is the first complete vertical slice. It does not introduce new framework APIs; instead it places capabilities from earlier pages on one screen: page-level shortcuts control the task list, SearchInput owns keyboard input while adding a task, and ConfirmModal owns keyboard input while confirming deletion.

todo app demo

This recording is generated by docs/tapes/todo-app.tape. The example moves between tasks and toggles completion, switches to the open filter, presses a to add Draft release notes, then opens the delete confirmation and presses y to remove the new task.

cargo run --example todo_app

Press j/k to move tasks, Space or Enter to toggle completion, f to cycle all/open/done filters, a to add a task, d to delete the current task, and q to exit.

TodoApp owns the business state; built-in components handle only local interaction and input isolation.

This example intentionally does not split state into a global Atom. The task list, cursor, filter, delete draft, and status only belong to the current application page, so local use_state is enough:

let todos = hooks.use_state(initial_todos);
let mut cursor = hooks.use_state(|| 0usize);
let mut filter = hooks.use_state(|| TodoFilter::All);
let mut draft = hooks.use_state(String::new);
let mut pending_delete = hooks.use_state(|| None::<usize>);
let mut status = hooks.use_state(|| "ready".to_string());

This boundary matches the guidance in Atom global state: put state in the smallest scope that works, and only promote it to Atom when multiple pages or long-lived caches need to share it.

The page-level handler handles only the background workflow:

hooks.use_event_handler(EventScope::Current, EventPriority::Normal, move |event| {
    let Event::Key(key) = event else {
        return EventResult::Ignored;
    };

    match key.code {
        KeyCode::Char('j') | KeyCode::Down => move_cursor(todos, cursor, filter.get(), 1),
        KeyCode::Char(' ') | KeyCode::Enter => toggle_current(todos, cursor, status),
        KeyCode::Char('f') => {
            let next_filter = filter.get().next();
            filter.set(next_filter);
        }
        KeyCode::Char('d') => pending_delete.set(Some(cursor.get())),
        _ => return EventResult::Ignored,
    }

    EventResult::Consumed
});

When no input field or modal is open, this handler belongs to the root layer and can handle j/k/Space/f/d/q normally. Once SearchInput or ConfirmModal opens, their exclusive layers cut off the background layer, so letters typed while adding a task do not also trigger page shortcuts.

Adding a task reuses SearchInput and only changes the activation key to a:

SearchInput(
    value: draft.read().to_string(),
    placeholder: "Press a to add a task".to_string(),
    activate_key: KeyCode::Char('a'),
    on_change: move |next: String| draft.set(next),
    on_submit: move |value: String| {
        let title = value.trim().to_string();
        if title.len() < 3 {
            status.set("task title needs at least 3 chars".to_string());
            return false;
        }

        todos.write().push(Todo { title, done: false });
        true
    },
    clear_on_submit: true,
    clear_on_escape: true,
)

SearchInput is still a controlled input: the displayed value comes from draft, and each input change calls on_change. After a successful submit, it clears the input and closes the edit layer. Returning false keeps edit mode open, which is useful for validation failures.

Deletion uses ConfirmModal:

ConfirmModal(
    open: pending_delete.get().is_some(),
    title: Line::from("Delete task?"),
    content: format!("Remove {pending_title}?"),
    confirm_text: "Delete".to_string(),
    cancel_text: "Keep".to_string(),
    on_confirm: move |_: ()| {
        if let Some(index) = pending_delete.get() {
            todos.write().remove(index);
        }
        pending_delete.set(None);
    },
    on_cancel: move |_: ()| pending_delete.set(None),
)

For ordinary confirmations, you do not need to write use_input_layer yourself. ConfirmModal already creates an exclusive layer internally and passes the same layer to the lower-level Modal. While the modal is open, the background list will not handle j/k/q, and unknown keys are consumed by the modal layer.

The earlier tutorials separately covered state, input isolation, Router, and components. todo_app puts them into a working interface:

  • Business state stays in the page component through use_state.
  • Page-level shortcuts return Consumed to avoid duplicate same-layer handling.
  • Built-in input components own their layers so keys do not leak through.
  • Complex workflows should compose existing components first; only add framework API when component semantics are not enough.

Next, return to the examples roadmap to keep organizing examples by basics / hooks / components / apps.