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

  1. Name the states before you name the variables An item is active or done. The app is idle or editing. Two booleans would give you four combinations, two of which are nonsense, and you would spend the afternoon defending against them.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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-action off whatever was pressed, is already wired for rows that do not exist yet.
  8. Treat storage as a hostile boundary Anything could be in localStorage: an older shape, another tab, a user with a console. Parse inside try, sanitise the result, then feed it in as a hydrate action 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 done

The 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 action

The 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 them

Three 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

  1. Add a toggleAll action, and make it return the same state when the list is already uniform.
  2. Add undo: keep the last twenty states in an array and add an undo action that pops one.
  3. Persist the filter as well as the items, then decide whether a restored filter belongs in hydrate or in its own action.
  4. Replay from the log: add a Replay button that starts from initialState() and re-applies every recorded action.
  5. Sync across tabs by listening for the storage event and dispatching hydrate when 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 to default and returns the state it was handed, so a === state. A real toggle builds a new object, so b !== 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 of done: 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 42 and the status to active, because sanitise normalises every field — The JSON is valid, so JSON.parse is happy. The defence is sanitise, which coerces the title, trims it, and accepts done as the only alternative to active. Parse errors are a different failure, caught by the try and reported without losing the app.

Common mistakes

  • Calling uid() or Date.now() inside the reducer, which quietly makes the same action produce different states and kills replay.
  • Mutating state.items with push and 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 localStorage to 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.