Tutorial: building a todo app
This tutorial walks through building a complete todo app, introducing one concept per step. By the end you’ll understand text inputs, dynamic lists, scoped IDs, commands, and conditional rendering.
Step 1: the model
Start with a model that tracks a list of todos and the current input text.
import gleam/list
import plushie/app
import plushie/gui
import plushie/command
import plushie/event.{type Event}
import plushie/node.{type Node}
import plushie/prop/length.{Fill}
import plushie/prop/padding
import plushie/ui
type Todo {
Todo(id: String, text: String, done: Bool)
}
type Filter {
All
Active
Done
}
type Model {
Model(todos: List(Todo), input: String, filter: Filter, next_id: Int)
}
fn init() {
#(Model(todos: [], input: "", filter: All, next_id: 1), command.none())
}
fn update(model: Model, _event: Event) {
#(model, command.none())
}
fn view(model: Model) -> Node {
ui.window("main", [ui.title("Todos")], [
ui.column("app", [ui.padding(padding.all(20.0)), ui.spacing(12), ui.width(Fill)], [
ui.text("title", "My Todos", [ui.font_size(24.0)]),
ui.text_("empty", "No todos yet"),
]),
])
}
pub fn main() {
gui.run(app.simple(init, update, view), gui.default_opts())
}
Run it with gleam run -m todo_app. You’ll see a title and a placeholder
message. Not much yet, but the structure is in place: init sets
up state, view renders it.
Step 2: adding a text input
Add a text input that updates the model on every keystroke, and a submit handler that creates a todo when the user presses Enter.
import plushie/event.{type Event, WidgetInput, WidgetSubmit}
import gleam/int
import gleam/string
fn update(model: Model, event: Event) {
case event {
WidgetInput(id: "new_todo", value: val, ..) -> #(
Model(..model, input: val),
command.none(),
)
WidgetSubmit(id: "new_todo", ..) ->
case string.trim(model.input) {
"" -> #(model, command.none())
_ -> {
let todo = Todo(
id: "todo_" <> int.to_string(model.next_id),
text: model.input,
done: False,
)
#(
Model(
..model,
todos: [todo, ..model.todos],
input: "",
next_id: model.next_id + 1,
),
command.none(),
)
}
}
_ -> #(model, command.none())
}
}
And the view:
fn view(model: Model) -> Node {
ui.window("main", [ui.title("Todos")], [
ui.column("app", [ui.padding(padding.all(20.0)), ui.spacing(12), ui.width(Fill)], [
ui.text("title", "My Todos", [ui.font_size(24.0)]),
ui.text_input("new_todo", model.input, [
ui.placeholder("What needs doing?"),
ui.on_submit(True),
]),
]),
])
}
Type something and press Enter. The input clears (the model’s
input resets to ""), but you can’t see the todos yet. Let’s
fix that.
Step 3: rendering the list with scoped IDs
Each todo needs its own row with a checkbox and a delete button. We wrap each item in a named container using the todo’s ID. This creates a scope – children get unique IDs automatically without manual prefixing.
fn view(model: Model) -> Node {
ui.window("main", [ui.title("Todos")], [
ui.column("app", [ui.padding(padding.all(20.0)), ui.spacing(12), ui.width(Fill)], [
ui.text("title", "My Todos", [ui.font_size(24.0)]),
ui.text_input("new_todo", model.input, [
ui.placeholder("What needs doing?"),
ui.on_submit(True),
]),
ui.column(
"list",
[ui.spacing(4)],
list.map(model.todos, fn(todo) {
ui.container(todo.id, [], [
ui.row("row", [ui.spacing(8)], [
ui.checkbox("toggle", "", todo.done, []),
ui.text_("text", todo.text),
ui.button_("delete", "x"),
]),
])
}),
),
]),
])
}
Each todo row has id: todo.id (e.g., "todo_1"). Inside it,
the checkbox has local id "toggle" and the button has "delete".
On the wire, these become "list/todo_1/row/toggle" and
"list/todo_1/row/delete" – unique across all items.
Step 4: handling toggle and delete with scope
When the checkbox or delete button is clicked, the event carries the
local id and a scope list with the todo’s row ID as the
immediate parent. Pattern match on both:
import plushie/event.{
type Event, WidgetClick, WidgetInput, WidgetSubmit, WidgetToggle,
}
fn update(model: Model, event: Event) {
case event {
// ... input and submit handlers from step 2 ...
WidgetToggle(id: "toggle", scope: [_row, todo_id, ..], ..) -> {
let todos = list.map(model.todos, fn(t) {
case t.id == todo_id {
True -> Todo(..t, done: !t.done)
False -> t
}
})
#(Model(..model, todos: todos), command.none())
}
WidgetClick(id: "delete", scope: [_row, todo_id, ..], ..) -> #(
Model(..model, todos: list.filter(model.todos, fn(t) { t.id != todo_id })),
command.none(),
)
_ -> #(model, command.none())
}
}
The scope: [_row, todo_id, ..] pattern binds the container’s ID
(e.g., "todo_1") regardless of how deep the row is nested. If you
later move the list into a sidebar or tab, the pattern still works.
Step 5: refocusing with a command
After submitting a todo, the text input loses focus. Let’s refocus
it automatically using command.focus:
WidgetSubmit(id: "new_todo", ..) ->
case string.trim(model.input) {
"" -> #(model, command.none())
_ -> {
let todo = Todo(
id: "todo_" <> int.to_string(model.next_id),
text: model.input,
done: False,
)
#(
Model(
..model,
todos: [todo, ..model.todos],
input: "",
next_id: model.next_id + 1,
),
command.focus("app/new_todo"),
)
}
}
Note the scoped path "app/new_todo" – the text input is inside
the "app" column, so its full ID is "app/new_todo". Commands
always use the full scoped path.
Step 6: filtering
Add filter buttons that toggle between all, active, and completed todos.
WidgetClick(id: "filter_all", ..) -> #(
Model(..model, filter: All),
command.none(),
)
WidgetClick(id: "filter_active", ..) -> #(
Model(..model, filter: Active),
command.none(),
)
WidgetClick(id: "filter_done", ..) -> #(
Model(..model, filter: Done),
command.none(),
)
Add the filter buttons and apply the filter in the view:
fn view(model: Model) -> Node {
ui.window("main", [ui.title("Todos")], [
ui.column("app", [ui.padding(padding.all(20.0)), ui.spacing(12), ui.width(Fill)], [
ui.text("title", "My Todos", [ui.font_size(24.0)]),
ui.text_input("new_todo", model.input, [
ui.placeholder("What needs doing?"),
ui.on_submit(True),
]),
ui.row("filters", [ui.spacing(8)], [
ui.button_("filter_all", "All"),
ui.button_("filter_active", "Active"),
ui.button_("filter_done", "Done"),
]),
ui.column(
"list",
[ui.spacing(4)],
list.map(filtered(model), fn(todo) {
todo_row(todo)
}),
),
]),
])
}
fn filtered(model: Model) -> List(Todo) {
case model.filter {
All -> model.todos
Active -> list.filter(model.todos, fn(t) { !t.done })
Done -> list.filter(model.todos, fn(t) { t.done })
}
}
fn todo_row(todo: Todo) -> Node {
ui.container(todo.id, [], [
ui.row("row", [ui.spacing(8)], [
ui.checkbox("toggle", "", todo.done, []),
ui.text_("text", todo.text),
ui.button_("delete", "x"),
]),
])
}
Notice todo_row is extracted as a view helper. In Gleam, each
function that builds UI nodes simply calls ui.* functions and
returns a Node.
The complete app
The full source is in
examples/todo.gleam
with tests in
test/plushie/examples/todo_test.gleam.
import gleam/int
import gleam/list
import gleam/string
import plushie/app
import plushie/gui
import plushie/command
import plushie/event.{
type Event, WidgetClick, WidgetInput, WidgetSubmit, WidgetToggle,
}
import plushie/node.{type Node}
import plushie/prop/length.{Fill}
import plushie/prop/padding
import plushie/ui
// -- Types -------------------------------------------------------------------
type Todo {
Todo(id: String, text: String, done: Bool)
}
type Filter {
All
Active
Done
}
type Model {
Model(todos: List(Todo), input: String, filter: Filter, next_id: Int)
}
// -- Init --------------------------------------------------------------------
fn init() {
#(Model(todos: [], input: "", filter: All, next_id: 1), command.none())
}
// -- Update ------------------------------------------------------------------
fn update(model: Model, event: Event) {
case event {
WidgetInput(id: "new_todo", value: val, ..) -> #(
Model(..model, input: val),
command.none(),
)
WidgetSubmit(id: "new_todo", ..) ->
case string.trim(model.input) {
"" -> #(model, command.none())
_ -> {
let todo = Todo(
id: "todo_" <> int.to_string(model.next_id),
text: model.input,
done: False,
)
#(
Model(
..model,
todos: [todo, ..model.todos],
input: "",
next_id: model.next_id + 1,
),
command.focus("app/new_todo"),
)
}
}
WidgetToggle(id: "toggle", scope: [_row, todo_id, ..], ..) -> {
let todos = list.map(model.todos, fn(t) {
case t.id == todo_id {
True -> Todo(..t, done: !t.done)
False -> t
}
})
#(Model(..model, todos: todos), command.none())
}
WidgetClick(id: "delete", scope: [_row, todo_id, ..], ..) -> #(
Model(..model, todos: list.filter(model.todos, fn(t) { t.id != todo_id })),
command.none(),
)
WidgetClick(id: "filter_all", ..) -> #(Model(..model, filter: All), command.none())
WidgetClick(id: "filter_active", ..) -> #(Model(..model, filter: Active), command.none())
WidgetClick(id: "filter_done", ..) -> #(Model(..model, filter: Done), command.none())
_ -> #(model, command.none())
}
}
// -- View --------------------------------------------------------------------
fn view(model: Model) -> Node {
ui.window("main", [ui.title("Todos")], [
ui.column("app", [ui.padding(padding.all(20.0)), ui.spacing(12), ui.width(Fill)], [
ui.text("title", "My Todos", [ui.font_size(24.0)]),
ui.text_input("new_todo", model.input, [
ui.placeholder("What needs doing?"),
ui.on_submit(True),
]),
ui.row("filters", [ui.spacing(8)], [
ui.button_("filter_all", "All"),
ui.button_("filter_active", "Active"),
ui.button_("filter_done", "Done"),
]),
ui.column(
"list",
[ui.spacing(4)],
list.map(filtered(model), fn(todo) {
todo_row(todo)
}),
),
]),
])
}
fn filtered(model: Model) -> List(Todo) {
case model.filter {
All -> model.todos
Active -> list.filter(model.todos, fn(t) { !t.done })
Done -> list.filter(model.todos, fn(t) { t.done })
}
}
fn todo_row(todo: Todo) -> Node {
ui.container(todo.id, [], [
ui.row("row", [ui.spacing(8)], [
ui.checkbox("toggle", "", todo.done, []),
ui.text_("text", todo.text),
ui.button_("delete", "x"),
]),
])
}
pub fn main() {
gui.run(app.simple(init, update, view), gui.default_opts())
}
What you’ve learned
- Text inputs with
ui.on_submit(True)for form-like behavior - Scoped IDs via named containers (
ui.container(todo.id, ..)) - Scope binding in update (
scope: [_row, todo_id, ..]) - Commands for side effects (
command.focuswith scoped paths) - Conditional rendering with filter functions
- View helpers extracted as private functions
Next steps
- Commands – async work, file dialogs, timers
- Scoped IDs – full scoping reference
- Composition patterns – scaling beyond a single module
- Testing – unit and integration testing