Ship a tiny task list
Combine everything so far into a small desktop program with input, validation, an empty state, and switches that respond immediately.
Outcome: you can type a task, reject an empty draft, add a row, and toggle its completion state.
01
Four ingredients, one useful loop
Each ingredient already appeared on its own. This chapter simply lets them cooperate.
Draft input
TextInput binds directly to State<String>, so the reducer can read the current draft when Add is dispatched.
Friendly validation
An empty or whitespace-only draft produces a visible message and leaves the list untouched.
Dynamic rows
The function that builds the content reads the task vector and creates one card per item, or one clear empty-state card.
Typed completion
Each Switch dispatches SetDone with its row index and proposed Boolean value.
02
Build the app one piece at a time
Assemble the task list in four small pieces. Read one piece, run the app, then move to the next.
Build it in small steps
Each code block changes one idea from the verified reference application.
Step 1: describe the data
Task is plain data. TaskState groups the reactive values, while TaskAction lists allowed intentions.
#[derive(Clone)]
struct Task {
title: String,
done: bool,
}
#[derive(Clone)]
enum TaskAction {
Add,
SetDone(usize, bool),
}
#[derive(Clone, Copy)]
struct TaskActions;
impl ActionSchema for TaskActions {
type Action = TaskAction;
}
#[derive(Clone)]
struct TaskState {
draft: State<String>,
tasks: State<Vec<Task>>,
feedback: State<String>,
has_error: State<bool>,
}Step 2: validate one addition
The Add branch reads the draft, rejects an empty title, or appends one task and clears the field.
TaskAction::Add => {
let title = state.draft.read().trim().to_owned();
if title.is_empty() {
state.feedback.set("Type a task first.".to_owned());
state.has_error.set(true);
} else {
state.tasks.update(|tasks| {
tasks.push(Task { title, done: false });
});
state.draft.set(String::new());
state.feedback.set("Task added. Nice!".to_owned());
state.has_error.set(false);
}
}Step 3: render one task row
Switch is controlled here: it displays task.done and emits SetDone when the user toggles it.
for (index, task) in tasks.into_iter().enumerate() {
list = list.child(
Card::new().padding(theme.spacing().md).child(
Row::new()
.gap(theme.spacing().md)
.align_items(AlignItems::Center)
.child(
Switch::new()
.checked(task.done)
.on_change(move |done| TaskAction::SetDone(index, done)),
)
.child(Text::new(task.title)),
),
);Step 4: assemble the screen
The final Column combines input, feedback, and the task list without hiding their separate responsibilities.
.child(
Row::new()
.gap(theme.spacing().md)
.child(
TextInput::new()
.bind(view_state.draft.clone())
.placeholder("Ship a friendly example")
.width(420.0),
)
.child(
Button::with_label("Add task")
.on_click(TaskAction::Add),
),
)
.child(
Text::new(view_state.feedback.clone()).style(TextStyle::new(
FontId::Ui,
14,
feedback_color,
)),
)
.child(task_list(&view_state, theme)),Compiled with Rust 1.88 against beta.2. These four snippets come from one verified fixture that uses only the public prelude.
03
Design every state, not only the happy path
A tiny application already feels complete when it explains what is happening before, during, and after an action.
Empty
The first card invites a useful action instead of leaving a mysterious blank area.
Needs a title
Submitting an empty draft shows a direct error and preserves the user's place.
Added
A successful add clears the input, creates a row, and answers with a small positive message.
Toggled
The switch can complete or reopen a task, and both directions update the feedback text.
04
You have a real foundation now
You composed a native window, applied theme tokens, shared reactive values, dispatched typed actions, validated input, and rendered a changing list.
WindowThemeStateActionSchemaTextInputSwitchKeep beta.2 close
Pin the exact dependency while the API is in beta, and recompile every example before adopting a newer version.
A tiny-list shortcut
This beginner version identifies rows by their vector index, and tasks.read() clones the whole Vec<Task>. That is fine for a tiny list. As data grows, give each task a stable ID, attach it to the row with .key(...), and prefer narrower updates.
Try it yourself
Exercise: tidy the task list
Add one small housekeeping action and one useful summary.
- 1Display the number of unfinished tasks with a derived text.
- 2Add ClearCompleted and a button that removes completed tasks.
Filter the vector inside State::update and return Commands::none when the mutation is complete.
The count changes after every toggle and the button removes only completed tasks.