---
title: Todo List
description: A fully interactive todo list built with client-side state actions.
icon: square-check
mode: center
---

import { ComponentPreview } from '/snippets/component-preview.mdx'

Multiple todo lists with inline editing, validation, and dynamic groups — all running client-side with no server calls.

<ComponentPreview json={{"view":{"cssClass":"pf-app-root","type":"Div","children":[{"cssClass":"gap-6 items-start","type":"Grid","minColumnWidth":"12rem"}]},"state":{"groups.{{ _loop_10_idx }}.todos.{{ _loop_11_idx }}.done":false,"groups.{{ _loop_10_idx }}.show_done":false,"groups":[{"name":"Work","todos":[{"text":"Find Magrathea","done":false},{"text":"Escape Vogons","done":false}],"new_todo":"","show_done":true},{"name":"Personal","todos":[{"text":"Have lunch at Milliways","done":true},{"text":"Buy towel","done":false}],"new_todo":"","show_done":true}]}}} playground=" ">
<CodeGroup>
```python Python icon="python" expandable
from prefab_ui.app import PrefabApp
from prefab_ui.components import (
    Button, Card, CardContent, CardFooter, CardHeader,
    Checkbox, Column, Form, Grid, Input, Muted, Row, Separator,
)
from prefab_ui.components.control_flow import ForEach, If
from prefab_ui.actions import AppendState, PopState, SetState
from prefab_ui.rx import Rx

# Each group has a name, a list of todos, an input buffer, and a visibility flag
with Grid(min_column_width="12rem", gap=6, align="start"):

    # Destructure to capture the group index (gi) and the group itself.
    # Each ForEach auto-captures $item and $index into scoped let bindings,
    # so nested loops don't shadow each other.
    with ForEach("groups") as (gi, group):
        with Card():
            with CardHeader():
                with Row(gap=2, align="center"):
                    Input(
                        name=f"groups.{gi}.name",
                        css_class="border-0 ring-0 shadow-none p-0 h-auto font-semibold text-lg focus-visible:ring-0 focus-visible:ring-offset-0",
                    )
                    Button("×", variant="ghost", size="sm", css_class="ml-auto", on_click=PopState("groups", gi))
            with CardContent():
                with Column(gap=4):
                    with Form(gap=0, on_submit=[
                        AppendState(f"groups.{gi}.todos", {"text": group.new_todo, "done": False}),
                        SetState(f"groups.{gi}.new_todo", ""),
                    ]):
                        with Row(gap=2):
                            Input(name=f"groups.{gi}.new_todo", placeholder="Add a todo...")
                            Button("Add", disabled=~group.new_todo)

                    with Column(gap=2):
                        with If(group.todos.length()):
                            Separator(spacing=3)
                        with ForEach(f"groups.{gi}.todos") as (ti, todo):
                            with If(~todo.done | group.show_done):
                                with Row(gap=2, align="center"):
                                    Checkbox(name=f"groups.{gi}.todos.{ti}.done")
                                    Input(
                                        name=f"groups.{gi}.todos.{ti}.text",
                                        css_class="border-0 ring-0 shadow-none p-0 h-auto focus-visible:ring-0 focus-visible:ring-offset-0",
                                    )
                                    Button("×", variant="ghost", size="sm", css_class="ml-auto", on_click=PopState(f"groups.{gi}.todos", ti))

            with CardFooter():
                with Row(gap=2, align="center", css_class="w-full justify-between"):
                    Muted(f"{group.todos.length()} items")
                    with Row(gap=1, align="center"):
                        Checkbox(name=f"groups.{gi}.show_done", css_class="h-3 w-3")
                        Muted("Show done")

    with Card(css_class="border-dashed p-3"):
        Button(
            "+",
            variant="ghost",
            size="lg",
            css_class="text-4xl text-muted-foreground w-full h-full min-h-48 rounded-md",
            on_click=AppendState("groups", {"name": "New List", "todos": [], "new_todo": "", "show_done": True}),
        )

app = PrefabApp(
    state={
        "groups": [
            {
                "name": "Work",
                "todos": [
                    {"text": "Find Magrathea", "done": False},
                    {"text": "Escape Vogons", "done": False},
                ],
                "new_todo": "",
                "show_done": True,
            },
            {
                "name": "Personal",
                "todos": [
                    {"text": "Have lunch at Milliways", "done": True},
                    {"text": "Buy towel", "done": False},
                ],
                "new_todo": "",
                "show_done": True,
            },
        ],
    },
    view=Grid(min_column_width="12rem", gap=6, align="start"),
)
```
```json Protocol icon="brackets-curly"
{
  "view": {
    "cssClass": "pf-app-root",
    "type": "Div",
    "children": [{"cssClass": "gap-6 items-start", "type": "Grid", "minColumnWidth": "12rem"}]
  },
  "state": {
    "groups.{{ _loop_10_idx }}.todos.{{ _loop_11_idx }}.done": false,
    "groups.{{ _loop_10_idx }}.show_done": false,
    "groups": [
      {
        "name": "Work",
        "todos": [
          {"text": "Find Magrathea", "done": false},
          {"text": "Escape Vogons", "done": false}
        ],
        "new_todo": "",
        "show_done": true
      },
      {
        "name": "Personal",
        "todos": [
          {"text": "Have lunch at Milliways", "done": true},
          {"text": "Buy towel", "done": false}
        ],
        "new_todo": "",
        "show_done": true
      }
    ]
  }
}
```
</CodeGroup>
</ComponentPreview>

## How It Works

The state is an array of groups, each containing a `name`, a `todos` array, and a `new_todo` input buffer. [ForEach](/components/foreach) iterates the groups, and a nested ForEach iterates each group's todos.

**Nested loops** — `with ForEach("groups") as (gi, group)` destructures the loop binding into an index (`gi`) and item (`group`), matching Python's `enumerate` convention. Each ForEach automatically captures `$item` and `$index` into scoped `let` bindings, so the outer loop's `group` and `gi` survive even after the inner loop shadows `$item` and `$index`. The inner `ForEach(f"groups.{gi}.todos") as (ti, todo)` uses `gi` to target the right group and `ti` to target individual todos.

**Inline editing** — instead of showing todo text as a `Text` component, each item uses a borderless `Input` styled with `border-0 shadow-none p-0` so it looks like plain text but is directly editable. [Dot-path auto-binding](/actions/update-state#dot-paths) handles the read and write — the input's `name` points at the exact item in state (`f"groups.{gi}.todos.{ti}.text"`).

**Disabling the Add button** — `disabled=~group.new_todo` evaluates to true when the input is empty, so the button stays disabled until you type something. `group` is the outer ForEach's item, so `group.new_todo` accesses the group's input buffer.

**Hiding completed items** — each group has a `show_done` flag toggled by a small checkbox in the footer. Inside the inner todo loop, `If(~todo.done | group.show_done)` checks the todo's completion against the group's visibility flag. Both references survive nesting because ForEach's auto-bind captured them.

**Adding a group** — the dashed card at the end appends a new group object with `AppendState("groups", {...})`. ForEach picks it up and renders a new card automatically.
