Todo App with a State Machine
Mental model: Every change is an action. One pure function turns state plus action into the next state, and the screen is just a picture of whatever that function last returned.
Level: intermediate · about 26 minutes
A todo list is the most written app in the world, which is exactly why it is worth building properly once. The list is not the point. The point is that by the end you have a pattern you can drop into any app that has state: one pure function owns every transition, the screen is a function of that state, and a log tells you what happened and in what order. That is Redux, that is useReducer, that is most of the state management you will ever meet.
Open the lab and you get a form, three filter tabs, the list, a mode readout, and an action log that grows as you work. Add a task and the log shows add { id: "task-1", title: "..." }. Press Edit and the mode chip flips from idle to editing. Type nothing and press Add: the log records the action and marks it ignored, because the reducer refused it. Reload the page and the list comes back, as a hydrate action like any other.
The build, decision by decision
- Name the states before you name the variables An item is
activeordone. The app isidleorediting. Two booleans would give you four combinations, two of which are nonsense, and you would spend the afternoon defending against them. - One function owns every transition The reducer reads its two arguments and returns a new object. No DOM, no storage, no
Date.now(), no random ids. Same arguments in, same answer out, every time. - Return the same object when nothing changed Toggling an id that is not in the list is not an error and not a change. Handing back the identical object makes "did anything happen?" a
!==check, which is what the log uses to print ignored. - Push the impurity out to the caller Ids and titles arrive on the action. The reducer never invents one. That single rule is what makes the action log replayable: run the same list of actions again and you land on the same state.
- Make illegal transitions unreachable Removing the row you were editing has to put the machine back in
idle, or you are editing a task that no longer exists. The reducer, not the click handler, is where that rule lives. - Dispatch, then render the whole thing One place applies the action, records it, saves it and redraws. Because render only reads state, there is no path where the screen and the data disagree.
- One listener for a list that keeps changing Rows are rebuilt on every render. A listener per row would have to be attached and removed each time. One listener on the list, reading
data-actionoff whatever was pressed, is already wired for rows that do not exist yet. - Treat storage as a hostile boundary Anything could be in
localStorage: an older shape, another tab, a user with a console. Parse insidetry, sanitise the result, then feed it in as ahydrateaction so restoring shows up in the log like everything else.
The core mechanism
const initial = { items: [], mode: 'idle', editingId: null, filter: 'all' };
function reducer(state, action) {
switch (action.type) {
case 'add': {
const title = String(action.title ?? '').trim();
if (!title) return state; // refused, same object back
return { ...state, items: [...state.items, { id: action.id, title, status: 'active' }] };
}
case 'toggle': {
if (!state.items.some((it) => it.id === action.id)) return state;
return {
...state,
items: state.items.map((it) =>
it.id === action.id ? { ...it, status: it.status === 'done' ? 'active' : 'done' } : it
),
};
}
default:
return state; // unknown action: not a crash
}
}
const actions = [
{ type: 'add', id: 't1', title: 'Read the reducer' },
{ type: 'add', id: 't2', title: ' ' },
{ type: 'toggle', id: 't1' },
{ type: 'toggle', id: 'ghost' },
];
let state = initial;
for (const action of actions) {
const next = reducer(state, action);
console.log(action.type, next === state ? 'ignored' : 'changed', next.items.map((it) => it.status).join(','));
state = next;
}
// -> add changed active
// -> add ignored active
// -> toggle changed done
// -> toggle ignored doneThe reducer plus a four-action replay. Two of the four actions change nothing, and you can see it.
const state = { items: [{ id: 'a', title: 'Tea', status: 'active' }], filter: 'all' };
const next = reducer(state, { type: 'toggle', id: 'nope' });
console.log(next === state, next.items === state.items);The guard clause returns state itself when the id is missing, so both the state object and its items array are the exact same references. That identity is not a micro optimisation, it is the signal: next === state means "nothing happened", which is how the log marks an action as ignored and how a framework skips a re-render.
The delegated listener works because a click on the trash icon bubbles up through the button and the row to the list. If that path is not yet second nature, step it through: the handler runs on the list, but event.target is the icon, which is why the lab matches with closest before reading data-action.
const list = { tag: 'ul', parent: null, dataset: {} };
const row = { tag: 'li', parent: list, dataset: { id: 'task-7' } };
const button = { tag: 'button', parent: row, dataset: { action: 'remove', id: 'task-7' } };
const svgIcon = { tag: 'svg', parent: button, dataset: {} };
function closest(node, test) {
let current = node;
while (current) {
if (test(current)) return current;
current = current.parent; // upward, so extra wrappers cannot break it
}
return null;
}
function onListClick(target) {
const el = closest(target, (n) => 'action' in n.dataset);
if (!el) {
console.log('ignored: that click was not on an action');
return;
}
console.log('dispatch ' + el.dataset.action + ' for ' + el.dataset.id);
}
onListClick(svgIcon); // the user aimed at the icon inside the button
onListClick(row); // and here at the row itself
// -> dispatch remove for task-7
// -> ignored: that click was not on an actionThe delegated handler, with the tree faked so you can run it anywhere. closest is what makes a click on an icon still find its button.
Interactive visualiser: eventflow. Enable JavaScript to use it.
Booleans, four combinations
let done = false;
let editing = false;
let deleted = false;
// what does this mean?
// { done: true, deleted: true }
// nothing in the code says,
// so every function guesses
One named state
item.status = 'active'; // or 'done'
state.mode = 'idle'; // or 'editing'
// the impossible combinations
// are not representable, so
// no code needs a defence
// against themThree booleans give you eight states and you meant three. Naming the states is not ceremony, it deletes the branches you would otherwise have to write and test.
Extend it
- Add a
toggleAllaction, and make it return the same state when the list is already uniform. - Add undo: keep the last twenty states in an array and add an
undoaction that pops one. - Persist the filter as well as the items, then decide whether a restored filter belongs in
hydrateor in its own action. - Replay from the log: add a Replay button that starts from
initialState()and re-applies every recorded action. - Sync across tabs by listening for the
storageevent and dispatchinghydratewhen another tab writes.
Try it yourself
Replay a log with reduce
const initial = { items: [], filter: 'all' };
function reducer(state, action) {
switch (action.type) {
case 'add':
return { ...state, items: [...state.items, { id: action.id, title: action.title, status: 'active' }] };
case 'setFilter':
return action.filter === state.filter ? state : { ...state, filter: action.filter };
default:
return state;
}
}
const log = [
{ type: 'add', id: 'a', title: 'Write the reducer' },
{ type: 'add', id: 'b', title: 'Write the render' },
{ type: 'setFilter', filter: 'active' },
{ type: 'setFilter', filter: 'active' },
];
const final = log.reduce(reducer, initial);
console.log(final.items.length, final.filter); // -> 2 active
// pure means replayable: same log, same answer
console.log(JSON.stringify(log.reduce(reducer, initial)) === JSON.stringify(final)); // -> true
Add a remove case, then replay the same log twice and check you land on the same state both times.
Only the legal transitions
const TRANSITIONS = {
idle: { edit: 'editing' },
editing: { commitEdit: 'idle', cancelEdit: 'idle' },
};
function next(mode, type) {
const target = TRANSITIONS[mode]?.[type];
if (!target) {
console.log('ignored ' + type + ' in mode ' + mode);
return mode;
}
return target;
}
let mode = 'idle';
mode = next(mode, 'edit'); // -> editing
mode = next(mode, 'edit'); // -> ignored edit in mode editing
mode = next(mode, 'commitEdit'); // -> idle
mode = next(mode, 'cancelEdit'); // -> ignored cancelEdit in mode idle
console.log(mode); // -> idle
Add a deleteWhileEditing transition. What should it do to editingId?
Exercises
Write the reducer
Write reducer(state, action) for a state of { items, filter }. Handle add (trim the title, refuse an empty one, status starts active), toggle (flip active and done), remove, and setFilter (only all, active or done). Never mutate the state you were given, and return the identical state object whenever an action changes nothing, including an unknown action type.
Sanitise what came out of storage
Write sanitise(value, makeId) for the storage boundary. Given anything at all, return a clean array of { id, title, status }. Skip entries that are not objects or whose title is empty after trimming, cut titles at 120 characters, treat any status other than the exact string done as active, keep a non-empty string id or call makeId() for one, and never return more than 200 items.
Check yourself
- What does this log?
true false— An unknown action falls todefaultand returns the state it was handed, soa === state. A real toggle builds a new object, sob !== state. Reference identity is the cheap answer to "did anything change", which is why every serious reducer keeps this discipline.- Why does the lab store
status: "active" | "done"instead ofdone: true | false? - It makes impossible combinations unrepresentable, so no code has to defend against them — With booleans you get a state space larger than the states you meant. Two booleans is four combinations, three is eight, and the extras leak into every
if. One named field has exactly the values you listed, so the nonsense cases never reach your code. - The list is rebuilt from scratch on every render. Why does the click handler keep working?
- The handler is on the list element, and clicks from rows bubble up to it — Delegation puts one listener on a node that never goes away, and relies on bubbling to bring events from children it has never seen. Rows created a minute from now are already handled, and there is nothing to detach when they are removed.
- A user has hand-edited the saved JSON so one entry is
{ "title": 42, "status": "maybe" }. What does the lab do? - Coerces the title to the string
42and the status toactive, becausesanitisenormalises every field — The JSON is valid, soJSON.parseis happy. The defence issanitise, which coerces the title, trims it, and acceptsdoneas the only alternative toactive. Parse errors are a different failure, caught by thetryand reported without losing the app.
Common mistakes
- Calling
uid()orDate.now()inside the reducer, which quietly makes the same action produce different states and kills replay. - Mutating
state.itemswithpushand then returning{ ...state }, so the array is shared and nothing can tell what changed. - Returning a fresh object for a no-op, which makes every action look like a change.
- Storing derived data (the filtered list, the counts) in state instead of computing it in render.
- Attaching a listener per row and rebuilding rows on every render, which leaks listeners or silently stops working.
- Trusting
localStorageto contain what you last wrote. Another tab, an older version, or a curious user says otherwise.
Takeaways
- One pure function owns every transition, and the screen is a function of its output.
- Explicit states delete the branches that boolean combinations force you to write.
- Returning the same state object is the signal for "nothing changed".
- Ids, clocks and randomness belong on the action, not in the reducer.
- One delegated listener outlives every row it handles.
- Storage is a boundary: parse in a
try, sanitise the result, and hydrate through an action.