Tutorial 2: Todo List
June 1, 2026 · View on GitHub
Build a todo list application that introduces text input handling, list rendering, keyed diffing, and memoization.
Prerequisites: Tutorial 1: Counter App
Time: 25 minutes
What you'll learn:
- Handling text input with
oninput - Rendering lists with keyed elements
- Memoizing list items with
lazyfor performance - Modeling complex state with nested records
- Filtering and derived views
The Model
A todo app needs a list of items, each with a description and completion status, plus an input field for new items:
using Picea.Abies.DOM;
using Picea.Abies.Subscriptions;
using Picea;
using static Picea.Abies.Html.Attributes;
using static Picea.Abies.Html.Elements;
using static Picea.Abies.Html.Events;
namespace MyTodo;
/// <summary>A single todo item.</summary>
public record TodoItem(Guid Id, string Text, bool Completed);
/// <summary>Which items to show.</summary>
public enum Filter { All, Active, Completed }
/// <summary>Complete application state.</summary>
public record Model(
string Input,
IReadOnlyList<TodoItem> Items,
Filter ActiveFilter);
Design decisions:
- Each
TodoItemhas aGuid Idfor stable identity across renders (important for keyed diffing) IReadOnlyList<T>enforces immutability at the type levelFilteris a simple enum — no need for a record when there's no associated data
Messages
Think about every interaction the user can perform:
public interface TodoMessage : Message;
/// <summary>The text in the input field changed.</summary>
public record InputChanged(string Value) : TodoMessage;
/// <summary>The user pressed Enter or clicked Add.</summary>
public record AddTodo : TodoMessage;
/// <summary>The user toggled a todo's completion.</summary>
public record ToggleTodo(Guid Id) : TodoMessage;
/// <summary>The user clicked delete on a todo.</summary>
public record RemoveTodo(Guid Id) : TodoMessage;
/// <summary>The user changed the active filter.</summary>
public record SetFilter(Filter Filter) : TodoMessage;
/// <summary>The user clicked "Clear completed".</summary>
public record ClearCompleted : TodoMessage;
Initialize
public sealed class TodoApp : Program<Model, Unit>
{
public static (Model, Command) Initialize(Unit argument) =>
(new Model(
Input: "",
Items: [],
ActiveFilter: Filter.All
), Commands.None);
Transition
The transition function handles every message with a pure state transformation:
public static (Model, Command) Transition(Model model, Message message) =>
message switch
{
InputChanged msg =>
(model with { Input = msg.Value }, Commands.None),
AddTodo when !string.IsNullOrWhiteSpace(model.Input) =>
(model with
{
Input = "",
Items = model.Items.Append(
new TodoItem(Guid.NewGuid(), model.Input.Trim(), false)
).ToList()
}, Commands.None),
AddTodo =>
(model, Commands.None), // empty input — do nothing
ToggleTodo msg =>
(model with
{
Items = model.Items
.Select(item => item.Id == msg.Id
? item with { Completed = !item.Completed }
: item)
.ToList()
}, Commands.None),
RemoveTodo msg =>
(model with
{
Items = model.Items
.Where(item => item.Id != msg.Id)
.ToList()
}, Commands.None),
SetFilter msg =>
(model with { ActiveFilter = msg.Filter }, Commands.None),
ClearCompleted =>
(model with
{
Items = model.Items
.Where(item => !item.Completed)
.ToList()
}, Commands.None),
_ => (model, Commands.None)
};
Key patterns:
- Guard clauses in pattern matching:
AddTodo when !string.IsNullOrWhiteSpace(model.Input)— only add if there's text - Immutable list operations:
Selectto transform,Whereto filter,Appendto add — always producing new collections - Two
AddTodocases: The first handles valid input, the second is the catch-all for empty input
View
The view composes several helper functions:
public static Document View(Model model)
{
var filtered = model.Items.Where(item => model.ActiveFilter switch
{
Filter.Active => !item.Completed,
Filter.Completed => item.Completed,
_ => true
}).ToList();
var activeCount = model.Items.Count(item => !item.Completed);
return new("Todo App",
div([class_("todo-app")],
[
h1([], [text("Todos")]),
InputSection(model.Input),
TodoList(filtered),
Footer(activeCount, model.ActiveFilter)
]));
}
The Input Section
static Node InputSection(string input) =>
div([class_("input-section")],
[
input([
class_("new-todo"),
placeholder("What needs to be done?"),
value(input),
oninput(data => new InputChanged(data?.Value ?? ""))
]),
button([class_("add-btn"), onclick(new AddTodo())], [text("Add")])
]);
The oninput handler uses the factory overload: oninput(Func<InputEventData?, Message>). When the user types, the runtime calls this function with the event data, and the resulting message flows into Transition. This is how you access the input value.
Compare the two onclick / oninput overloads:
| Overload | Use Case |
|---|---|
onclick(new Increment()) | No event data needed — message is fixed |
oninput(data => new InputChanged(data?.Value ?? "")) | Need the input value from the event |
The Todo List with Keyed Rendering
static Node TodoList(IReadOnlyList<TodoItem> items) =>
ul([class_("todo-list")],
items.Select(item =>
lazy(item.Id, () => TodoItemView(item))
).ToArray());
lazy(key, factory) is the memoization primitive. It:
- Uses
item.Idas a stable identity key - Caches the virtual DOM node for each key
- Only calls the
factoryfunction when the key is new or the item has changed - Enables the differ to match old and new list items by key instead of by position
Without lazy, reordering a 100-item list would re-render all 100 items. With lazy, only the items that actually changed are re-rendered.
Individual Todo Item
static Node TodoItemView(TodoItem item) =>
li([class_(item.Completed ? "todo completed" : "todo")],
[
input([
type("checkbox"),
checked_(item.Completed),
onclick(new ToggleTodo(item.Id))
]),
span([class_("text")], [text(item.Text)]),
button([class_("delete"), onclick(new RemoveTodo(item.Id))], [text("×")])
]);
Filter Footer
static Node Footer(int activeCount, Filter current) =>
div([class_("footer")],
[
span([], [text($"{activeCount} item{(activeCount == 1 ? "" : "s")} left")]),
div([class_("filters")],
[
FilterButton("All", Filter.All, current),
FilterButton("Active", Filter.Active, current),
FilterButton("Completed", Filter.Completed, current)
]),
button([class_("clear"), onclick(new ClearCompleted())], [text("Clear completed")])
]);
static Node FilterButton(string label, Filter filter, Filter current) =>
button([
class_(filter == current ? "filter-btn active" : "filter-btn"),
onclick(new SetFilter(filter))
], [text(label)]);
Subscriptions
public static Subscription Subscriptions(Model model) =>
SubscriptionModule.None;
public static Result<Message[], Message> Decide(Model state, Message command) =>
Result<Message[], Message>.Ok([command]);
public static bool IsTerminal(Model state) => false;
}
Understanding Keyed Diffing
Consider a list of three todos: A, B, C. The user deletes B.
Without keys (positional diffing):
Old: [A, B, C] → Position 0: A→A (no change)
New: [A, C] → Position 1: B→C (update text, checkbox, handlers)
→ Position 2: C→∅ (remove)
Two DOM operations, and item C is unnecessarily re-rendered.
With keys (keyed diffing via lazy):
Old: {id-a: A, id-b: B, id-c: C}
New: {id-a: A, id-c: C}
→ id-a: matched, no change
→ id-b: removed
→ id-c: matched, no change
One DOM operation, and item C keeps its existing DOM node.
Principle: This is the same reconciliation by key algorithm used by React, Elm, and other virtual DOM frameworks. The insight, formalized by Hunt & Szymanski (1977), is that when list items have stable identities, computing the minimal edit script is O(n log n) instead of O(n²).
Performance with Memoization
The lazy function uses the key to check a view cache (default capacity: 2,000 entries). If the key is found and the underlying data hasn't changed, the cached virtual DOM subtree is reused without calling the factory function.
For a list of 1,000 items where only one changed, this means:
- Without
lazy: 1,000 factory calls → 1,000 virtual DOM subtrees → 1,000 diffs - With
lazy: 1 factory call → 1 virtual DOM subtree → 1 diff + 999 cache hits
Testing
[Test]
public async Task AddTodo_AppendsItemToList()
{
var model = new Model("", [], Filter.All)
with { Input = "Buy groceries" };
var (newModel, _) = TodoApp.Transition(model, new AddTodo());
await Assert.That(newModel.Items).HasSingleItem();
await Assert.That(newModel.Items[0].Text).IsEqualTo("Buy groceries");
await Assert.That(newModel.Items[0].Completed).IsFalse();
await Assert.That(newModel.Input).IsEmpty(); // input cleared after add
}
[Test]
public async Task AddTodo_WithEmptyInput_DoesNothing()
{
var model = new Model("", [], Filter.All);
var (newModel, _) = TodoApp.Transition(model, new AddTodo());
await Assert.That(newModel.Items).IsEmpty();
}
[Test]
public async Task ToggleTodo_FlipsCompletedState()
{
var id = Guid.NewGuid();
var items = new List<TodoItem> { new(id, "Test", false) };
var model = new Model("", items, Filter.All);
var (newModel, _) = TodoApp.Transition(model, new ToggleTodo(id));
await Assert.That(newModel.Items[0].Completed).IsTrue();
}
[Test]
public async Task ClearCompleted_RemovesOnlyCompletedItems()
{
var items = new List<TodoItem>
{
new(Guid.NewGuid(), "Done", true),
new(Guid.NewGuid(), "Not done", false),
new(Guid.NewGuid(), "Also done", true)
};
var model = new Model("", items, Filter.All);
var (newModel, _) = TodoApp.Transition(model, new ClearCompleted());
await Assert.That(newModel.Items).HasSingleItem();
await Assert.That(newModel.Items[0].Text).IsEqualTo("Not done");
}
Exercises
-
Double-click to edit — Add an
Editingstate toTodoItemand handleondblclickto enter edit mode. You'll need new messages for starting/finishing editing. -
Persist to localStorage — Use commands and an interpreter to save/load todos from browser localStorage (you'll learn this pattern in Tutorial 3).
-
Drag and drop reordering — Use subscriptions to handle drag events and reorder the list.
-
"Select all" toggle — Add a checkbox that toggles all items' completion state at once.
Key Concepts
| Concept | In This Tutorial |
|---|---|
oninput(data => msg) | Accessing input values from DOM events |
lazy(key, factory) | Memoized rendering with stable keys |
| Keyed diffing | O(n log n) list reconciliation |
| Immutable collections | Select, Where, Append → new list |
| Guard patterns | AddTodo when !string.IsNullOrWhiteSpace(...) |
| Derived views | Filtering in View, not in the model |
Next Steps
→ Tutorial 3: API Integration — Learn commands and the interpreter pattern for side effects