
There’s a bug on the orders screen this morning. The filter says shipped, the details panel is showing a cancelled order, and the Save button is lit with nothing to save. You’ve been clicking for twenty minutes and you can’t make it happen twice. The state is right there in the devtools (statusFilter: "shipped", selectedId: "o-118", dirty: true) and it’s useless, because state tells you where the program ended up and nothing about how it got there. A state snapshot is a crime scene photo: the body’s final position, none of the events.
Last piece I argued that the decisions leaving your logic should be values: commands, plain data a shell performs. This piece aims the same argument the opposite direction. The history entering your logic deserves to be a value too. And once you write history down, state stops being something you set and becomes something you compute.
Written by setters, witnessed by no one
Here’s where the morning’s bug lives. It’s the state of the orders view, and it looks like every React component you’ve ever inherited:
const [statusFilter, setStatusFilter] = useState<Status | "all">("all");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [drafts, setDrafts] = useState<Record<string, Draft>>({});
const [dirty, setDirty] = useState(false);
Four slices of state, four setters, and a handler layer that pokes them in combinations:
const changeFilter = (status: Status | "all") => {
setStatusFilter(status);
setSelectedId(null); // added after bug #412
};
const selectOrder = (id: string) => {
setSelectedId(id);
setDirty(false); // ...right?
};
const editNote = (id: string, note: string) => {
setDrafts(d => ({ ...d, [id]: { ...d[id], note } }));
setDirty(true);
};
Read the comments, because they’re the fossil record. changeFilter clears the selection because bug #412 taught someone that filtering can hide the selected order. selectOrder resets dirty, and nobody remembers whether that’s a rule or an accident. The rules about how these four slices move together live nowhere. Or rather, they live in whichever handler last remembered them, one bug fix at a time, and the next handler is free to forget.
So the devtools show you the residue of every setter call so far, applied in an order nobody wrote down. That’s why the bug won’t reproduce: reproducing it means guessing the sequence, and the sequence is gone. The errors piece was about types that permit values which can’t be true. This is the same disease in the time dimension. Handlers that permit histories which should never happen.
Record what happened, not what to change
The move is the one this series keeps making, pointed at a new target. Stop poking the state. Start recording facts:
type ViewEvent =
| { kind: "filter_changed"; status: Status | "all" }
| { kind: "order_selected"; id: string }
| { kind: "note_edited"; id: string; note: string }
| { kind: "drafts_saved" };
A discriminated union, taking its fourth job of this series after errors, order states, and commands. And read the tense, because the tense is the design. Last piece’s Command was future tense: request_shipment, a wish on its way out to the shell, which might refuse it. An event is past tense: note_edited, a fact on its way in. Wishes can fail. Facts have already happened; all they ask is to be written down.
React is going to call these “actions” the moment we touch useReducer, and Redux made that the industry’s word. The trouble with “action” is that it’s agnostic about tense. Half the reducers in production are full of commands wearing action suits, SET_FILTER, FETCH_ORDERS, and a reducer fed commands drifts into being a place where things get done. Name them as events, past tense, and half your design questions answer themselves: a fact carries exactly what happened, a fact can’t fetch anything, and recording a fact is never the wrong move, even when acting on it would be.
One function owns every transition
Facts need a judge: one pure function that rules on what each event means for the state.
type Draft = { note?: string; quantity?: number };
type ViewState = {
statusFilter: Status | "all";
selectedId: string | null;
drafts: Record<string, Draft>;
};
const update = (state: ViewState, event: ViewEvent): ViewState => {
switch (event.kind) {
case "filter_changed":
return { ...state, statusFilter: event.status, selectedId: null };
case "order_selected":
return { ...state, selectedId: event.id };
case "note_edited":
return {
...state,
drafts: {
...state.drafts,
[event.id]: { ...state.drafts[event.id], note: event.note },
},
};
case "drafts_saved":
return { ...state, drafts: {} };
default:
return assertNever(event);
}
};
The bug-#412 rule, filter changes clear the selection, is now one line that holds no matter which handler would have forgotten it. assertNever is the exhaustiveness lock from the errors piece: add a ViewEvent variant and every judge that hasn’t ruled on it stops compiling. And dirty has left the state entirely, because it was never state. It was an opinion about state:
const dirty = Object.keys(state.drafts).length > 0;
State that could lie, deleted instead of synchronized. The save that drafts_saved reports is an effect, and it stays in the shell: the shell performs the POST, then dispatches the fact it made true. (And yes, that note_edited case is a three-level spread to change one string. It works, and it offends me. There’s a cleaner way to reach that deep; it gets a piece of its own later.)
Now the line that pays for all of it. update takes a state and an event and returns a state, which is exactly the shape reduce eats:
const state = events.reduce(update, initialState);
Same events in, same state out, every time. It’s the equation this series opened on, now running your screen. And it changes what a bug report is. This morning’s mystery arrived as a screenshot and an apology. Here it is as data:
const report: ViewEvent[] = [
{ kind: "order_selected", id: "o-118" },
{ kind: "note_edited", id: "o-118", note: "leave at the door" },
{ kind: "filter_changed", status: "shipped" },
];
Fold it and look: the filter change cleared the selection, because the rule lives in the judge now. Producing this morning’s corpse takes a sequence of pokes the judge will never allow. The bug isn’t fixed. It’s unexpressible.
React has shipped a home for this fold since 16.8:
const [state, dispatch] = useReducer(update, initialState);
dispatch changes nothing. It files a report; React runs it through your judge and keeps the running total. You’ve stopped setting state. You narrate what happened, and one function decides what it means.
You’ve been folding the whole time
You have used this shape a hundred times. In Six Functional Patterns, sumTotals collapsed an array of orders into a number: a starting value, a combining function, a sequence. Functional programming’s name for that shape is a fold, and you’ve been folding for years without the word: totals, groupings, maxes. You’ve also been using half of it. Every fold you’ve written pointed at space, at an array already sitting in memory. useReducer is the identical fold pointed at time. Events are a sequence like any other; they just arrive one click apart instead of one index apart.
State is a fold over everything that has happened.
selectedId isn’t a thing you have. It’s the running total of every selection and every filter change so far. The devtools were never showing you state. They were showing you a subtotal.
Keep the events, derive the screen
useReducer runs the fold and throws away the tape: it keeps the running total and discards the events. A sensible default. Also a choice, and nothing forces it:
const [events, setEvents] = useState<ViewEvent[]>([]);
const dispatch = (event: ViewEvent) => setEvents(prev => [...prev, event]);
const state = useMemo(() => events.reduce(update, initialState), [events]);
Keep the tape and features start falling out of the fold. Undo is a slice:
const undo = () => setEvents(prev => prev.slice(0, -1));
No inverse operations, no undo stack drifting out of sync with reality. Fold a shorter history, get an earlier world. Time travel is the same trick with a cursor: fold events.slice(0, n) and you’re looking at the exact screen from n facts ago. And the unreproducible bug dies for good, because the report can now attach the log. A bug report with an event log is a reproduction, not an anecdote.
You’ve seen this productionized. Redux DevTools’ time-travel debugger, the scrubber demo that sold half the industry on a state library, is this fold with a UI on it: record the events, refold a prefix. No magic was ever involved.
The tape has a price, so pay it only where it pays back. Logs grow without bound, refolding is work (that useMemo is doing real lifting), and the fold-and-discard useReducer is the right default for most screens. Keep the log where replay is a feature: undo, audit, the bug that only appears on the third Tuesday.
Your orders were already folds
Now look underneath the view state, because last piece closed on a promise about the data itself: every order in your list is the sum of its events. The Order union from the errors piece says a shipped order has a tracking number and a cancelled one has a reason. It names the stops. It says nothing about the journey, and nothing is born shipped:
type OrderEvent =
| { kind: "placed"; id: string; customer: string; total: number; at: string }
| { kind: "shipped"; trackingNumber: string; at: string }
| { kind: "delivered"; at: string }
| { kind: "cancelled"; reason: string; at: string };
const apply = (order: Order, event: OrderEvent): Order => {
if (order.status === "cancelled") return order; // tombstones don't read their mail
switch (event.kind) {
case "shipped":
return { ...order, status: "shipped", trackingNumber: event.trackingNumber };
case "cancelled":
return { ...order, status: "cancelled", reason: event.reason };
// "placed" seeds the fold; "delivered" mirrors "shipped"
}
};
const [placed, ...rest] = history;
const order = rest.reduce(apply, fromPlaced(placed));
(fromPlaced copies four fields onto status: "pending"; ten seconds of typing.) The errors piece made illegal states unrepresentable: no shipped order without a tracking number, by construction. apply finishes the thought for illegal histories: a cancelled order that receives a late shipped event returns unchanged, every time, in one auditable place. The type ruled out the impossible noun. The fold rules on the impossible sentence.
Two details are worth stealing even if you never keep a log in production. First, at rides in on the event. apply never asks the clock, because asking the clock is an effect and effects live in the shell; the shell stamps the fact when it records it, and replays stay honest forever. Second, notice what the folded state can’t tell you. { status: "cancelled", reason: "customer request" } answers what. Only the log answers the questions that arrive at 5pm: the cancellation came ninety seconds after the second failed payment retry, from the support console, before the shipment went out. State shrugs. The log testifies.
The backend world built a whole discipline on that observation (keep the events as the source of truth, derive state as a view) and named it event sourcing. Be clear-eyed about the price tag: it’s a commitment, not a default. Events are forever, and every event shape you ever emit becomes a document format you’ll be reading until the end of time. Adopt it where the history is the business. Which, in at least one industry, it literally is.
The most expensive fold in the world
An exchange’s order book, the data structure real money moves through, is a fold over an event stream. Adds, cancels, fills, tens of thousands a second; the book you see quoted is the running accumulator. And when something needs explaining, and in markets something always needs explaining, nobody flips through screenshots of state. They refold: the book at 09:31:07.114 is the fold of every event up to 09:31:07.114, reconstructed on demand, bit for bit. The most reliability-obsessed systems on the planet do not store the state of the market. They store what happened and compute the market. Your orders screen can afford the same honesty.
The whole engine
Set this piece beside the last one. Effects Are Values made the core’s outputs data: commands a shell performs. This piece made the core’s inputs data: events a fold consumes. One signature holds both:
type Update = (state: State, event: Event) => [State, Command[]];
Events in. State folded. Commands out. That line is the entire Elm architecture, the one React borrowed its rendering half from. It’s a Redux store with its middleware, drawn in one type. And you’ve now hand-rolled all of it, in plain TypeScript, with nothing installed.
Where a fold is overkill
A checkbox does not need an event log. useState remains the right tool for the toggle, the input draft, the hover flag: state with one writer, no rules binding it to its neighbors, and nobody asking how it got that way. Reducers earn their keep when transitions branch, when fields move together, when yesterday’s sequence is today’s bug report. The kept log earns its keep one notch later still: when undo, audit, or replay is a feature, and a growing array is a fair price for it. It’s the same test this series runs on every technique: pay for structure exactly where the pain is, and nowhere else.
Ask what happened
One more thing came out of the fold, and it’s the biggest. update and apply are pure. No clock, no network, no mocks: feed events, check state, the same zero-mock tests the core earned last piece. But a pure reducer deserves better than the three examples you’ll think up over coffee. A machine can generate ten thousand event sequences you’d never imagine, fold every one, and check that the laws survive: an edit followed by its undo restores the original; no sequence of facts leaves a hidden order selected; cancelled stays cancelled no matter what arrives late. You don’t write those test cases. You state the law, and a tool hunts down the counterexample and shrinks it to the three-event story that breaks you. That’s a piece of its own, and it’s the one this whole series has been building toward: Don’t Write Tests, Write Properties.
Next time a screen ends up somewhere strange, ask the only two questions that ever mattered: what happened, and in what order. If you kept the events, the answer is one fold away. State was never the story. It’s the running total of one. Keep the story.