Every so often a bit of frontend advice I've been half-ignoring for years gets cheaper to follow, and lately it's state machines. They haven't changed at all, they're the same boxes and arrows that have been sitting in textbooks for decades, but the agents writing more and more of my teams' UI code have quietly removed the excuse I used to lean on for skipping them.
I'm clearly not the only one thinking it. Matt Pocock, who created Total TypeScript and now teaches AI engineering full-time at AI Hero, posted this on 15 October:
Given Matt spent time on the XState core team, him being a fan of state machines isn't exactly a shock, but it was the agents half of that sentence that caught my attention. What I want to get down is why it's started to feel like a default in the way my teams and I work with the agents, rather than the nice-to-have it always was when a human was the one typing every reducer by hand.
Here's where I've landed, and I'll only say it the once:
When the agents are writing your UI, a reducer or a state machine is the cheapest guardrail you can ask them for, because it makes the contradictory combinations of flags impossible to express instead of something a reviewer has to spot, and it hands you a transition table that's small enough for a human to actually review.
The agents take the shortest path, so pave it
Anyone who's spent a few weeks reviewing agent PRs will have noticed that the agents are enthusiastic copiers. They look at what's already in the file, do more of that, and take the shortest route to a green build, which is lovely when the file is good and mildly alarming when it isn't. So the job, as I see it, is to make the easy thing and the right thing the same thing, ideally in code rather than in a paragraph of AGENTS.md that the agent may or may not have bothered to read.
In a React component built out of useState flags, the shortest path for any new requirement is another flag. Ask an agent to add a retry and you'll very likely get an isRetrying, ask it for cancellation and along comes isCancelled, and every one of those diffs looks perfectly sensible on its own. Nobody ever asks for a component that's loading and errored at the same time while still showing last week's data, but three booleans give you eight combinations, only about four of them make sense, and nothing in the code is stopping the other four.
The nastiest version of this is async. Someone cancels, the request they cancelled finally lands a second later, and the screen flicks back to data they've already walked away from. With booleans that's yet another flag to juggle, whereas with explicit states it's just a transition you can refuse and write a test for. If you've ever chased that one through a pile of useEffects you'll know exactly the bug I mean.
// Each of these arrived in a different, perfectly sensible PR
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
const [order, setOrder] = useState<Order | null>(null);
// ...which means this is now a state the UI can be in,
// and every render has to defend against it
// isLoading && isError && isRetrying && order !== nullNow the same thing with a discriminated union and a reducer. It's a few more lines, and every one of them is boring in a good way:
type State =
| { status: "idle" }
| { status: "loading"; attempt: number }
| { status: "success"; order: Order }
| { status: "error"; message: string; attempt: number };
type Event =
| { type: "FETCH" }
| { type: "RESOLVE"; order: Order; attempt: number }
| { type: "REJECT"; message: string; attempt: number }
| { type: "RETRY" }
| { type: "RESET" };
export function orderReducer(state: State, event: Event): State {
switch (state.status) {
case "idle":
return event.type === "FETCH" ? { status: "loading", attempt: 1 } : state;
case "loading":
// only the attempt we're waiting on gets a say, stale responses are ignored
if (event.type === "RESOLVE" && event.attempt === state.attempt) {
return { status: "success", order: event.order };
}
if (event.type === "REJECT" && event.attempt === state.attempt) {
return { status: "error", message: event.message, attempt: state.attempt };
}
return state;
case "error":
if (event.type === "RETRY") return { status: "loading", attempt: state.attempt + 1 };
if (event.type === "RESET") return { status: "idle" };
return state;
case "success":
return event.type === "RESET" ? { status: "idle" } : state;
default: {
const unhandled: never = state; // add a status and forget it? tsc says no
return unhandled;
}
}
}The bit I like most is the never at the bottom. If an agent decides the order flow needs a cancelled status and adds it to State without handling it, the type checker fails the build before a human has even opened the PR. That's a whole class of problem removed by the compiler rather than by a reviewer squinting at a diff, and it's the same argument I made in TypeScript is the contract your agents keep breaking, just pointed at UI state instead of API shapes.
The attempt check in the loading branch is that late-response bug dealt with in two lines. It's awkward to bolt onto a pile of booleans and fairly obvious once there's a state to hang it on, so it's exactly the sort of thing I'd want in every agent-written fetch flow.
It also flips what the shortest path looks like. Once the reducer exists, the cheapest thing for an agent to do with a new requirement is add a state or an event, because that's what the code around it is already doing, and agents really do love to copy what exists. The right thing has become the easy thing, and nobody had to write a paragraph in AGENTS.md asking for it.
Review the table, not the diff
The other half of this is about review, and it's the half I care about more. Reading a 400-line agent diff full of effects and flags is hit and miss, because every pass turns up something new and you're never quite sure whether you've found the last bug or just the latest one. A reducer gives you something much smaller to look at, since the transitions read top to bottom and a missing one tends to stand out instead of hiding in an effect three screens down. The catch is that the agents will cheerfully invent states nobody asked for, so I'd much rather see the table before I see any code.
So that's the process I want my teams using. Ask for the transition table first (in the PR description is fine), review it as the contract it is, and only then let the agent write the reducer and the tests. Matt's point about future updates being trivial and testable falls straight out of this, since every row in the table is a test case and every missing row is a conversation you'd rather have before the code exists. It's also a lot harder for an agent to fudge a table you've already signed off than a test it wrote itself after the fact, which I moaned about at some length last week.
When a component or hook has more than one piece of state that changes together (loading, error, retry, cancel, submitted and so on), model it as a discriminated union with a status field and a reducer, not as separate useState booleans. Before writing the code, put the transition table in the PR description as rows of current state, event and next state, and list any state or event you've added that wasn't in the ticket. Handle every status explicitly and finish the switch with a never check so an unhandled status fails typecheck. Write one test per row of the table, plus at least one test proving that an event arriving in the wrong state (for example a late response after a reset) leaves the state unchanged. Use a plain reducer by default, and only reach for XState if the flow needs nested or parallel states, then say why in the PR.
The last line is there because the XState-or-plain-reducer question always comes up, and my answer is fairly boring. A union and a reducer will cover most components, and when your teams hit nested or parallel states, or want a diagram they can wave at a product manager, XState earns its place. Nobody needs the whole statechart experience to fetch an order, and if an agent reaches for it on a two-field form I'd want it to explain itself in the PR.
An aside, because the agents should be state machines too
The thing that keeps nagging at me is that the agents themselves need this just as much. An agent loop is a set of states (planning, calling a tool, waiting on a human, done) with transitions between them, and yet a lot of agent control flow still lives in a markdown prompt that politely asks the model to do things in the right order. Building an agent isn't really that different from building a frontend app, and I'd like the same boring guarantees for both.
I've even got a whole post on getting agent control flow out of markdown and into a real process. It's still sitting unpublished in my CMS, which is a slightly awkward thing to admit in a post about managing state. One day it'll transition out of draft, probably.
And for the avoidance of doubt, not everything needs this. A modal that's open or closed is a boolean, and wrapping it in a statechart is how you end up in a code review argument that nobody wins. I'd start reaching for a reducer the moment a second flag starts touching the same bit of UI, and I wouldnt bother before then.
Thoughts
I think Matt's onto something, and for me it's less about state machines suddenly becoming cool than about the agents making the trade-off disappear. Writing a reducer used to cost a human some extra typing up front, so we put it off until the bug report, whereas an agent will write one in seconds and then quite happily keep extending it for you.
If your teams are letting agents loose on frontend code, I'd ask for the state machine by default, have the transition table land in the PR before the code does, and put your review effort into that table rather than the effects soup underneath it. It's a small change to your AGENTS.md, and it means the agent's shortcut and the right answer end up being the same thing, which is about as much as any of us can reasonably ask of the agents.
&w=3840&q=75)


