Skip to content

todo

A reducer drives the list; use_persisted keeps it across restarts.

Everything that can change the list is a message — Add, Toggle, Remove, ClearDone — and one reduce function applies them. use_reducer hands back the state and a Dispatch, so a button deep in the list can say what happened without a chain of callbacks above it.

The data itself comes from use_persisted, keyed by the explicit string "todo/todos". Persisted slots are identified by that key rather than by the call site, precisely so that inserting a line above does not make saved data unreadable — and the example prefixes the key with its own name because everything an app persists shares one namespace. Run it natively, quit, and start it again: the list is still there, in eframe's storage.

The non-obvious bit is inside the loop. todos is borrowed by the for, so a handler there cannot touch it: the remove button sends Msg::Remove(i) and the checkbox binds to a scratch copy, with the real change going through the Dispatch and landing on the next frame. A reducer is one way out of that borrow; update_later is the other.

Watch the two tabs. The plain egui version keeps the same Vec<Todo> and does the same work; what it also keeps is the bookkeeping — the messages, the persistence, and the deletions it has to defer by hand.

Run it yourself ​

sh
cargo run -p todo
cargo run -p todo --bin todo-plain    # the plain egui version
trunk serve --config examples/todo/Trunk.toml

The source is examples/todo/src/lib.rs and plain.rs.

rust
use egui_reactor::prelude::*;
use egui_reactor_elements::prelude::*;
use serde::{Deserialize, Serialize};

/// One item. `pub` so [`plain`] can share it: the data model is the same, only
/// the code around it differs.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Todo {
    pub text: String,
    pub done: bool,
}

/// Everything that can change the list.
enum Msg {
    Add(String),
    Toggle(usize),
    Remove(usize),
    ClearDone,
}

fn reduce(todos: &mut Vec<Todo>, msg: Msg) {
    match msg {
        Msg::Add(text) => todos.push(Todo { text, done: false }),
        Msg::Toggle(i) => {
            if let Some(todo) = todos.get_mut(i) {
                todo.done = !todo.done;
            }
        }
        Msg::Remove(i) => {
            if i < todos.len() {
                todos.remove(i);
            }
        }
        Msg::ClearDone => todos.retain(|todo| !todo.done),
    }
}

#[component]
pub fn App(cx: &mut Cx) {
    // The data lives in storage; the reducer is what changes it. The key is
    // prefixed with the example name because the gallery runs every example
    // against one storage slot.
    let mut saved = use_persisted(cx, "todo/todos", Vec::<Todo>::new);
    let mut draft = use_state(cx, String::new);

    let (todos, dispatch) = use_reducer(
        cx,
        |state: &mut Vec<Todo>, msg| reduce(state, msg),
        || saved.clone(),
    );
    // Mirror the reducer's state into the persisted slot; both are cheap Vecs
    // and this keeps the storage copy up to date without a second reducer.
    if *saved != *todos {
        *saved = todos.clone();
    }

    let remaining = todos.iter().filter(|todo| !todo.done).count();
    let done: Vec<(usize, String)> = todos
        .iter()
        .enumerate()
        .filter(|(_, todo)| todo.done)
        .map(|(i, todo)| (i, todo.text.clone()))
        .collect();

    rsx! {
        <View direction="column" gap={8} p={12} grow={1.0}>
            <Text size={22.0} strong>"todo"</Text>

            <View direction="row" gap={8} align="center">
                // The field owns the `&mut String`, so `on_submit` gets the
                // text as its payload and `clear_on_submit` empties the field.
                <TextEdit
                    grow={1.0}
                    bind={draft.bind()}
                    hint="what needs doing?"
                    clear_on_submit
                    on_submit={|text: String| {
                        let text = text.trim().to_owned();
                        if !text.is_empty() {
                            dispatch.send(Msg::Add(text));
                        }
                    }}
                />
                <Text>{format!("{remaining} left")}</Text>
            </View>

            <Separator/>

            for (i, todo) in todos.iter().enumerate().filter(|(_, t)| !t.done) {
                <View key={i} direction="row" gap={8} align="center">
                    // `todos` is borrowed by the loop, so the checkbox binds to
                    // a scratch copy and the real change goes through the
                    // `Dispatch`, which lands on the next frame.
                    <Checkbox
                        bind={&mut { todo.done }}
                        on_change={|_: bool| dispatch.send(Msg::Toggle(i))}
                    />
                    <Text grow={1.0}>{todo.text.as_str()}</Text>
                    // `label` is what a screen reader says; "x" is what is drawn.
                    <Button label="remove" on_click={|| dispatch.send(Msg::Remove(i))}>"x"</Button>
                </View>
            }

            if !done.is_empty() {
                <Collapsing header={&format!("done ({})", done.len())}>
                    <View direction="column" gap={4}>
                        for (i, text) in done.iter() {
                            <View key={i} direction="row" gap={8} align="center">
                                <Text grow={1.0}>{text.as_str()}</Text>
                                <Button on_click={|| dispatch.send(Msg::Toggle(*i))}>"undo"</Button>
                            </View>
                        }
                        <Button on_click={|| dispatch.send(Msg::ClearDone)}>"clear done"</Button>
                    </View>
                </Collapsing>
            }
        </View>
    }
}
rust
use serde::{Deserialize, Serialize};

use crate::Todo;

/// Everything the plain version keeps between frames.
///
/// The egui-reactor version has no such type. `saved`, `draft` and the reducer's
/// list are three hooks, each owned by the component that reads it.
#[derive(Default, Serialize, Deserialize)]
pub struct PlainState {
    pub todos: Vec<Todo>,
    /// The text field's buffer. Not saved: it is not part of the list.
    #[serde(skip)]
    pub draft: String,
}

/// The key the standalone binary stores the list under.
pub const STORAGE_KEY: &str = "todo_plain";

impl PlainState {
    /// Read the list back. `use_persisted` is this, plus the key.
    pub fn load(json: &str) -> Self {
        serde_json::from_str(json).unwrap_or_default()
    }

    /// Serialize the list for the caller to write into eframe's storage.
    pub fn save(&self) -> String {
        serde_json::to_string(self).unwrap_or_else(|_| String::from("{}"))
    }
}

pub fn ui(ui: &mut egui::Ui, state: &mut PlainState) {
    // `p={12}` and `gap={8}` on the root `<View>`.
    egui::Frame::new().inner_margin(12.0).show(ui, |ui| {
        ui.spacing_mut().item_spacing.y = 8.0;
        ui.label(egui::RichText::new("todo").size(22.0).strong());

        let remaining = format!("{} left", state.todos.iter().filter(|t| !t.done).count());
        ui.horizontal(|ui| {
            // `<TextEdit grow={1.0}>` next to a label: the field's width is
            // whatever the label does not need.
            let label_width = text_width(ui, &remaining);
            let width = ui.available_width() - label_width - ui.spacing().item_spacing.x;
            let edit = ui.add_sized(
                egui::vec2(width, ui.spacing().interact_size.y),
                egui::TextEdit::singleline(&mut state.draft).hint_text("what needs doing?"),
            );
            if edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
                let text = state.draft.trim().to_owned();
                if !text.is_empty() {
                    state.todos.push(Todo { text, done: false });
                }
                state.draft.clear();
            }
            ui.label(remaining);
        });

        ui.separator();

        // The loop borrows the list, so it cannot remove from it. The index
        // is carried out here and applied below; the egui-reactor version
        // sends `Msg::Remove(i)` and never sees this.
        let mut remove = None;
        for (i, todo) in state
            .todos
            .iter_mut()
            .enumerate()
            .filter(|(_, todo)| !todo.done)
        {
            ui.horizontal(|ui| {
                ui.checkbox(&mut todo.done, "");
                ui.label(&todo.text);
                // `<Text grow={1.0}>` before the button: here the button is
                // put in a right-to-left `Ui` filling the rest of the row.
                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                    let button = ui.button("x");
                    // `<Button label="remove">`, by hand: an "x" is drawn but
                    // "remove" is the name assistive technology reads.
                    ui.ctx()
                        .accesskit_node_builder(button.id, |node| node.set_label("remove"));
                    if button.clicked() {
                        remove = Some(i);
                    }
                });
            });
        }
        if let Some(i) = remove {
            state.todos.remove(i);
        }

        let done: Vec<usize> = state
            .todos
            .iter()
            .enumerate()
            .filter(|(_, todo)| todo.done)
            .map(|(i, _)| i)
            .collect();
        if !done.is_empty() {
            // Same again: the clicks are collected, then applied.
            let mut undo = None;
            let mut clear = false;
            egui::CollapsingHeader::new(format!("done ({})", done.len()))
                .id_salt("done")
                .show(ui, |ui| {
                    ui.spacing_mut().item_spacing.y = 4.0;
                    for &i in &done {
                        ui.horizontal(|ui| {
                            ui.label(&state.todos[i].text);
                            ui.with_layout(
                                egui::Layout::right_to_left(egui::Align::Center),
                                |ui| {
                                    if ui.button("undo").clicked() {
                                        undo = Some(i);
                                    }
                                },
                            );
                        });
                    }
                    clear = ui.button("clear done").clicked();
                });
            if let Some(i) = undo {
                state.todos[i].done = false;
            }
            if clear {
                state.todos.retain(|todo| !todo.done);
            }
        }
    });
}

/// The width `text` will take as a default label.
fn text_width(ui: &egui::Ui, text: &str) -> f32 {
    let font = egui::TextStyle::Body.resolve(ui.style());
    ui.painter()
        .layout_no_wrap(text.to_owned(), font, egui::Color32::PLACEHOLDER)
        .size()
        .x
}

Released under the MIT or Apache-2.0 license.