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

Next steps

Search Document