Skip to content

Lists

Render a dynamic list with FlatList, use keys for stable reconciliation, and hook in delete and toggle actions. This example builds a simple to-do list.

Paste it into app/main.py of a project scaffolded with pn init, then run pn preview app.main.TodoList.

The code

from collections.abc import Callable
from dataclasses import dataclass, replace

import pythonnative as pn


@dataclass(frozen=True)
class Todo:
    id: str
    title: str
    done: bool = False


INITIAL_TODOS: tuple[Todo, ...] = (
    Todo("1", "Try PythonNative", done=True),
    Todo("2", "Build a real app"),
    Todo("3", "Profit"),
)


@pn.component
def TodoRow(todo: Todo, on_toggle: Callable[[str], None], on_delete: Callable[[str], None]) -> pn.Node:
    return pn.Row(
        pn.Switch(value=todo.done, on_change=lambda value: on_toggle(todo.id)),
        pn.Text(
            todo.title,
            style={
                "flex": 1,
                "font_size": 16,
                "color": "#888888" if todo.done else "#000000",
            },
        ),
        pn.Button("Delete", on_press=lambda: on_delete(todo.id)),
        style={"gap": 12, "padding": 8, "align_items": "center"},
    )


@pn.component
def TodoList() -> pn.Node:
    todos, set_todos = pn.use_state(INITIAL_TODOS)
    draft, set_draft = pn.use_state("")

    def add() -> None:
        title = draft.strip()
        if not title:
            return
        new_id = str(max(int(t.id) for t in todos) + 1) if todos else "1"
        set_todos((*todos, Todo(new_id, title)))
        set_draft("")

    def toggle(todo_id: str) -> None:
        set_todos(tuple(replace(t, done=not t.done) if t.id == todo_id else t for t in todos))

    def delete(todo_id: str) -> None:
        set_todos(tuple(t for t in todos if t.id != todo_id))

    return pn.Column(
        pn.Row(
            pn.TextInput(
                value=draft,
                on_change=set_draft,
                placeholder="What needs doing?",
                style={"flex": 1},
            ),
            pn.Button("Add", on_press=add),
            style={"gap": 8, "padding": 16},
        ),
        pn.FlatList(
            data=todos,
            render_item=lambda t, index: TodoRow(todo=t, on_toggle=toggle, on_delete=delete),
            key_extractor=lambda t, index: t.id,
            style={"flex": 1},
        ),
    )

Why keys matter

The reconciler matches children by key first and by position only as a fallback. Position-based keys can transfer a row's local state to another item after a deletion. With stable keys, "todo 2" keeps its identity even when its index shifts, as long as it remains in the mounted window.

FlatList keys each row with key_extractor, which reads the same todo.id the row's callbacks use. When you render rows yourself (in a Column, say), key your own components with .with_key(): pn.Column(*(TodoRow(t, toggle, delete).with_key(t.id) for t in todos)). Built-in elements take key= directly. When in doubt: use a stable identifier that's part of the data, not the position.

Performance notes

  • FlatList lazily mounts only the rows that are visible (or near visible); the native list owns scrolling, item geometry, and recycling. For long lists it scales much better than wrapping a Column in a ScrollView.
  • Avoid recomputing data on every render. If you derive it from another piece of state, wrap it in use_memo.
  • For row callbacks that you pass deeply, consider use_callback to keep references stable.
  • Ordinary sequences are convenient for small, derived collections. For large collections with frequent edits, use ListData to publish keyed changes without scanning every item. See the incremental list guide.

Sorting and filtering

Sorting and filtering are pure functions over the todos tuple; do them at render time:

visible = sorted((t for t in todos if not hide_done or not t.done), key=lambda t: t.title)

If the input list is large, memoize the result:

visible = pn.use_memo(
    lambda: sorted((t for t in todos if not hide_done or not t.done), key=lambda t: t.title),
    [todos, hide_done],
)

Next steps