---
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":{"gi":"{{ $index }}","show_done":"{{ $item.show_done }}"},"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","type":"Input","inputType":"text","name":"groups.{{ gi }}.name","disabled":false,"required":false},{"cssClass":"ml-auto","type":"Button","label":"\u00d7","variant":"ghost","size":"sm","disabled":false,"onClick":{"action":"popState","key":"groups","index":"{{ gi }}"}}]}]},{"type":"CardContent","children":[{"cssClass":"gap-4","type":"Column","children":[{"cssClass":"gap-0","type":"Form","onSubmit":[{"action":"appendState","key":"groups.{{ gi }}.todos","value":{"text":"{{ $item.new_todo }}","done":false}},{"action":"setState","key":"groups.{{ gi }}.new_todo","value":""}],"children":[{"cssClass":"gap-2","type":"Row","children":[{"type":"Input","inputType":"text","placeholder":"Add a todo...","name":"groups.{{ gi }}.new_todo","disabled":false,"required":false},{"type":"Button","label":"Add","variant":"default","size":"default","disabled":"{{ not $item.new_todo }}"}]}]},{"cssClass":"gap-2","type":"Column","children":[{"type":"Condition","cases":[{"when":"{{ $item.todos | length }}","children":[{"cssClass":"my-3","type":"Separator","orientation":"horizontal"}]}]},{"type":"ForEach","key":"groups.{{ gi }}.todos","children":[{"type":"Condition","cases":[{"when":"{{ not $item.done or show_done }}","children":[{"cssClass":"gap-2 items-center","type":"Row","children":[{"type":"Checkbox","checked":false,"name":"groups.{{ gi }}.todos.{{ $index }}.done","disabled":false,"required":false},{"cssClass":"border-0 ring-0 shadow-none p-0 h-auto focus-visible:ring-0 focus-visible:ring-offset-0","type":"Input","inputType":"text","name":"groups.{{ gi }}.todos.{{ $index }}.text","disabled":false,"required":false},{"cssClass":"ml-auto","type":"Button","label":"\u00d7","variant":"ghost","size":"sm","disabled":false,"onClick":{"action":"popState","key":"groups.{{ gi }}.todos","index":"{{ $index }}"}}]}]}]}]}]}]}]},{"type":"CardFooter","children":[{"cssClass":"gap-2 items-center w-full justify-between","type":"Row","children":[{"content":"{{ $item.todos | length }} items","type":"Muted"},{"cssClass":"gap-1 items-center","type":"Row","children":[{"cssClass":"h-3 w-3","type":"Checkbox","checked":false,"name":"groups.{{ gi }}.show_done","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":[{"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>
```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, ForEach, Form, Grid, If, Input, Muted, Row, Separator,
)
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"):

    # Iterate groups; `let` captures the group index as `gi` and
    # the group's show_done flag so they're available in the inner loop
    with ForEach("groups", let=dict(gi="{{ $index }}", show_done="{{ $item.show_done }}")):
        with Card():
            with CardHeader():
                with Row(gap=2, align="center"):
                    # Editable group name — borderless input styled as a title
                    Input(
                        name="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):
                    # New todo input + add button — Form makes Enter submit too
                    with Form(gap=0, on_submit=[
                        AppendState("groups.{{ gi }}.todos", {"text": "{{ $item.new_todo }}", "done": False}),
                        SetState("groups.{{ gi }}.new_todo", ""),
                    ]):
                        with Row(gap=2):
                            Input(name="groups.{{ gi }}.new_todo", placeholder="Add a todo...")
                            Button("Add", disabled="{{ not $item.new_todo }}")

                    # Inner loop over todos — gi keeps the outer group index
                    with Column(gap=2):
                        with If("{{ $item.todos | length }}"):
                            Separator(spacing=3)
                        with ForEach("groups.{{ gi }}.todos"):
                            with If("{{ not $item.done or show_done }}"):
                                with Row(gap=2, align="center"):
                                    Checkbox(name="groups.{{ gi }}.todos.{{ $index }}.done")
                                    # Borderless input looks like text but is directly editable
                                    Input(
                                        name="groups.{{ gi }}.todos.{{ $index }}.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("groups.{{ gi }}.todos", "{{ $index }}"))

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

    # Dashed card to add a new group
    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": {
          "gi": "{{ $index }}",
          "show_done": "{{ $item.show_done }}"
        },
        "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",
                        "type": "Input",
                        "inputType": "text",
                        "name": "groups.{{ gi }}.name",
                        "disabled": false,
                        "required": false
                      },
                      {
                        "cssClass": "ml-auto",
                        "type": "Button",
                        "label": "\u00d7",
                        "variant": "ghost",
                        "size": "sm",
                        "disabled": false,
                        "onClick": {
                          "action": "popState",
                          "key": "groups",
                          "index": "{{ gi }}"
                        }
                      }
                    ]
                  }
                ]
              },
              {
                "type": "CardContent",
                "children": [
                  {
                    "cssClass": "gap-4",
                    "type": "Column",
                    "children": [
                      {
                        "cssClass": "gap-0",
                        "type": "Form",
                        "onSubmit": [
                          {
                            "action": "appendState",
                            "key": "groups.{{ gi }}.todos",
                            "value": {
                              "text": "{{ $item.new_todo }}",
                              "done": false
                            }
                          },
                          {
                            "action": "setState",
                            "key": "groups.{{ gi }}.new_todo",
                            "value": ""
                          }
                        ],
                        "children": [
                          {
                            "cssClass": "gap-2",
                            "type": "Row",
                            "children": [
                              {
                                "type": "Input",
                                "inputType": "text",
                                "placeholder": "Add a todo...",
                                "name": "groups.{{ gi }}.new_todo",
                                "disabled": false,
                                "required": false
                              },
                              {
                                "type": "Button",
                                "label": "Add",
                                "variant": "default",
                                "size": "default",
                                "disabled": "{{ not $item.new_todo }}"
                              }
                            ]
                          }
                        ]
                      },
                      {
                        "cssClass": "gap-2",
                        "type": "Column",
                        "children": [
                          {
                            "type": "Condition",
                            "cases": [
                              {
                                "when": "{{ $item.todos | length }}",
                                "children": [
                                  {
                                    "cssClass": "my-3",
                                    "type": "Separator",
                                    "orientation": "horizontal"
                                  }
                                ]
                              }
                            ]
                          },
                          {
                            "type": "ForEach",
                            "key": "groups.{{ gi }}.todos",
                            "children": [
                              {
                                "type": "Condition",
                                "cases": [
                                  {
                                    "when": "{{ not $item.done or show_done }}",
                                    "children": [
                                      {
                                        "cssClass": "gap-2 items-center",
                                        "type": "Row",
                                        "children": [
                                          {
                                            "type": "Checkbox",
                                            "checked": false,
                                            "name": "groups.{{ gi }}.todos.{{ $index }}.done",
                                            "disabled": false,
                                            "required": false
                                          },
                                          {
                                            "cssClass": "border-0 ring-0 shadow-none p-0 h-auto focus-visible:ring-0 focus-visible:ring-offset-0",
                                            "type": "Input",
                                            "inputType": "text",
                                            "name": "groups.{{ gi }}.todos.{{ $index }}.text",
                                            "disabled": false,
                                            "required": false
                                          },
                                          {
                                            "cssClass": "ml-auto",
                                            "type": "Button",
                                            "label": "\u00d7",
                                            "variant": "ghost",
                                            "size": "sm",
                                            "disabled": false,
                                            "onClick": {
                                              "action": "popState",
                                              "key": "groups.{{ gi }}.todos",
                                              "index": "{{ $index }}"
                                            }
                                          }
                                        ]
                                      }
                                    ]
                                  }
                                ]
                              }
                            ]
                          }
                        ]
                      }
                    ]
                  }
                ]
              },
              {
                "type": "CardFooter",
                "children": [
                  {
                    "cssClass": "gap-2 items-center w-full justify-between",
                    "type": "Row",
                    "children": [
                      {
                        "content": "{{ $item.todos | length }} items",
                        "type": "Muted"
                      },
                      {
                        "cssClass": "gap-1 items-center",
                        "type": "Row",
                        "children": [
                          {
                            "cssClass": "h-3 w-3",
                            "type": "Checkbox",
                            "checked": false,
                            "name": "groups.{{ gi }}.show_done",
                            "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": [
      {
        "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/for-each) iterates the groups, and a nested ForEach iterates each group's todos.

**Nested loops with `let`** — the outer ForEach uses `let` to capture the group index as `gi` and the group's visibility flag as `show_done`. Inside the inner todo loop, `$index` and `$item` refer to the todo, but `gi` and `show_done` are still available. This gives us paths like `groups.{{ gi }}.todos.{{ $index }}.done` for targeting a specific todo in a specific group.

**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/set-state#dot-paths) handles the read and write — the input's `name` points at the exact item in state (`groups.{{ gi }}.todos.{{ $index }}.text`).

**Disabling the Add button** — `disabled="{{ not $item.new_todo }}"` evaluates to true when the input is empty, so the button stays disabled until you type something.

**Hiding completed items** — each group has a `show_done` flag toggled by a small checkbox in the footer. The `let` binding makes it available inside the inner loop, where each todo row is wrapped in `If("{{ not $item.done or show_done }}")`.

**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.
