---
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":"gap-6 items-start","type":"Grid","minColumnWidth":"12rem","children":[{"let":{"_loop_10":"{{ $item }}","_loop_10_idx":"{{ $index }}"},"type":"ForEach","key":"groups","children":[{"type":"Card","children":[{"type":"CardHeader","children":[{"cssClass":"gap-2 items-center","type":"Row","children":[{"cssClass":"border-0 ring-0 shadow-none p-0 h-auto font-semibold text-lg focus-visible:ring-0 focus-visible:ring-offset-0","name":"groups.{{ _loop_10_idx }}.name","type":"Input","inputType":"text","disabled":false,"readOnly":false,"required":false},{"cssClass":"ml-auto","type":"Button","label":"\u00d7","variant":"ghost","size":"sm","disabled":false,"onClick":{"action":"popState","key":"groups","index":"{{ _loop_10_idx }}"}}]}]},{"type":"CardContent","children":[{"cssClass":"gap-4","type":"Column","children":[{"cssClass":"gap-0","type":"Form","onSubmit":[{"action":"appendState","key":"groups.{{ _loop_10_idx }}.todos","value":{"text":"{{ _loop_10.new_todo }}","done":false}},{"action":"setState","key":"groups.{{ _loop_10_idx }}.new_todo","value":""}],"children":[{"cssClass":"gap-2","type":"Row","children":[{"name":"groups.{{ _loop_10_idx }}.new_todo","type":"Input","inputType":"text","placeholder":"Add a todo...","disabled":false,"readOnly":false,"required":false},{"type":"Button","label":"Add","variant":"default","size":"default","disabled":"{{ !_loop_10.new_todo }}"}]}]},{"cssClass":"gap-2","type":"Column","children":[{"type":"Condition","cases":[{"when":"{{ _loop_10.todos | length }}","children":[{"cssClass":"my-3","type":"Separator","orientation":"horizontal"}]}]},{"let":{"_loop_11":"{{ $item }}","_loop_11_idx":"{{ $index }}"},"type":"ForEach","key":"groups.{{ _loop_10_idx }}.todos","children":[{"type":"Condition","cases":[{"when":"{{ !_loop_11.done || _loop_10.show_done }}","children":[{"cssClass":"gap-2 items-center","type":"Row","children":[{"name":"groups.{{ _loop_10_idx }}.todos.{{ _loop_11_idx }}.done","value":false,"type":"Checkbox","disabled":false,"required":false},{"cssClass":"border-0 ring-0 shadow-none p-0 h-auto focus-visible:ring-0 focus-visible:ring-offset-0","name":"groups.{{ _loop_10_idx }}.todos.{{ _loop_11_idx }}.text","type":"Input","inputType":"text","disabled":false,"readOnly":false,"required":false},{"cssClass":"ml-auto","type":"Button","label":"\u00d7","variant":"ghost","size":"sm","disabled":false,"onClick":{"action":"popState","key":"groups.{{ _loop_10_idx }}.todos","index":"{{ _loop_11_idx }}"}}]}]}]}]}]}]}]},{"type":"CardFooter","children":[{"cssClass":"gap-2 items-center w-full justify-between","type":"Row","children":[{"content":"{{ _loop_10.todos | length }} items","type":"Muted"},{"cssClass":"gap-1 items-center","type":"Row","children":[{"cssClass":"h-3 w-3","name":"groups.{{ _loop_10_idx }}.show_done","value":false,"type":"Checkbox","disabled":false,"required":false},{"content":"Show done","type":"Muted"}]}]}]}]}]},{"cssClass":"border-dashed p-3","type":"Card","children":[{"cssClass":"text-4xl text-muted-foreground w-full h-full min-h-48 rounded-md","type":"Button","label":"+","variant":"ghost","size":"lg","disabled":false,"onClick":{"action":"appendState","key":"groups","value":{"name":"New List","todos":[],"new_todo":"","show_done":true}}}]}]},"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 set_initial_state
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

# Each group has a name, a list of todos, an input buffer, and a visibility flag
set_initial_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,
        },
    ],
)

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}),
        )
```
```json Protocol icon="brackets-curly"
{
  "view": {
    "cssClass": "gap-6 items-start",
    "type": "Grid",
    "minColumnWidth": "12rem",
    "children": [
      {
        "let": {"_loop_10": "{{ $item }}", "_loop_10_idx": "{{ $index }}"},
        "type": "ForEach",
        "key": "groups",
        "children": [
          {
            "type": "Card",
            "children": [
              {
                "type": "CardHeader",
                "children": [
                  {
                    "cssClass": "gap-2 items-center",
                    "type": "Row",
                    "children": [
                      {
                        "cssClass": "border-0 ring-0 shadow-none p-0 h-auto font-semibold text-lg focus-visible:ring-0 focus-visible:ring-offset-0",
                        "name": "groups.{{ _loop_10_idx }}.name",
                        "type": "Input",
                        "inputType": "text",
                        "disabled": false,
                        "readOnly": false,
                        "required": false
                      },
                      {
                        "cssClass": "ml-auto",
                        "type": "Button",
                        "label": "\u00d7",
                        "variant": "ghost",
                        "size": "sm",
                        "disabled": false,
                        "onClick": {"action": "popState", "key": "groups", "index": "{{ _loop_10_idx }}"}
                      }
                    ]
                  }
                ]
              },
              {
                "type": "CardContent",
                "children": [
                  {
                    "cssClass": "gap-4",
                    "type": "Column",
                    "children": [
                      {
                        "cssClass": "gap-0",
                        "type": "Form",
                        "onSubmit": [
                          {
                            "action": "appendState",
                            "key": "groups.{{ _loop_10_idx }}.todos",
                            "value": {"text": "{{ _loop_10.new_todo }}", "done": false}
                          },
                          {"action": "setState", "key": "groups.{{ _loop_10_idx }}.new_todo", "value": ""}
                        ],
                        "children": [
                          {
                            "cssClass": "gap-2",
                            "type": "Row",
                            "children": [
                              {
                                "name": "groups.{{ _loop_10_idx }}.new_todo",
                                "type": "Input",
                                "inputType": "text",
                                "placeholder": "Add a todo...",
                                "disabled": false,
                                "readOnly": false,
                                "required": false
                              },
                              {
                                "type": "Button",
                                "label": "Add",
                                "variant": "default",
                                "size": "default",
                                "disabled": "{{ !_loop_10.new_todo }}"
                              }
                            ]
                          }
                        ]
                      },
                      {
                        "cssClass": "gap-2",
                        "type": "Column",
                        "children": [
                          {
                            "type": "Condition",
                            "cases": [
                              {
                                "when": "{{ _loop_10.todos | length }}",
                                "children": [{"cssClass": "my-3", "type": "Separator", "orientation": "horizontal"}]
                              }
                            ]
                          },
                          {
                            "let": {"_loop_11": "{{ $item }}", "_loop_11_idx": "{{ $index }}"},
                            "type": "ForEach",
                            "key": "groups.{{ _loop_10_idx }}.todos",
                            "children": [
                              {
                                "type": "Condition",
                                "cases": [
                                  {
                                    "when": "{{ !_loop_11.done || _loop_10.show_done }}",
                                    "children": [
                                      {
                                        "cssClass": "gap-2 items-center",
                                        "type": "Row",
                                        "children": [
                                          {
                                            "name": "groups.{{ _loop_10_idx }}.todos.{{ _loop_11_idx }}.done",
                                            "value": false,
                                            "type": "Checkbox",
                                            "disabled": false,
                                            "required": false
                                          },
                                          {
                                            "cssClass": "border-0 ring-0 shadow-none p-0 h-auto focus-visible:ring-0 focus-visible:ring-offset-0",
                                            "name": "groups.{{ _loop_10_idx }}.todos.{{ _loop_11_idx }}.text",
                                            "type": "Input",
                                            "inputType": "text",
                                            "disabled": false,
                                            "readOnly": false,
                                            "required": false
                                          },
                                          {
                                            "cssClass": "ml-auto",
                                            "type": "Button",
                                            "label": "\u00d7",
                                            "variant": "ghost",
                                            "size": "sm",
                                            "disabled": false,
                                            "onClick": {
                                              "action": "popState",
                                              "key": "groups.{{ _loop_10_idx }}.todos",
                                              "index": "{{ _loop_11_idx }}"
                                            }
                                          }
                                        ]
                                      }
                                    ]
                                  }
                                ]
                              }
                            ]
                          }
                        ]
                      }
                    ]
                  }
                ]
              },
              {
                "type": "CardFooter",
                "children": [
                  {
                    "cssClass": "gap-2 items-center w-full justify-between",
                    "type": "Row",
                    "children": [
                      {"content": "{{ _loop_10.todos | length }} items", "type": "Muted"},
                      {
                        "cssClass": "gap-1 items-center",
                        "type": "Row",
                        "children": [
                          {
                            "cssClass": "h-3 w-3",
                            "name": "groups.{{ _loop_10_idx }}.show_done",
                            "value": false,
                            "type": "Checkbox",
                            "disabled": false,
                            "required": false
                          },
                          {"content": "Show done", "type": "Muted"}
                        ]
                      }
                    ]
                  }
                ]
              }
            ]
          }
        ]
      },
      {
        "cssClass": "border-dashed p-3",
        "type": "Card",
        "children": [
          {
            "cssClass": "text-4xl text-muted-foreground w-full h-full min-h-48 rounded-md",
            "type": "Button",
            "label": "+",
            "variant": "ghost",
            "size": "lg",
            "disabled": false,
            "onClick": {
              "action": "appendState",
              "key": "groups",
              "value": {"name": "New List", "todos": [], "new_todo": "", "show_done": true}
            }
          }
        ]
      }
    ]
  },
  "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.
