JIMDRURY.
Jim Drury
Flat pink editorial graphic of a patent-style statechart with four stippled sphere states (idle, loading, success, error) joined by FETCH, RESOLVE, REJECT, RETRY and RESET events, three crossed-out boolean cubes labelled isLoading, isError and isRetrying, and a retro transitions.ts window listing the legal transitions with an impossible success REJECT row struck through
agents

Ask your agents for a state machine, not another useState

10 MIN READ
- Matt Pocock (@mattpocockuk), 15 Oct 2026, on X

The agents take the shortest path, so pave it

Boolean soup, one reasonable diff at a time (illustrative)
// 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 !== null
The same component, with nowhere to sneak a boolean in (illustrative)
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;
    }
  }
}

Review the table, not the diff

<What I'd put in AGENTS.md for UI state>

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.

</What I'd put in AGENTS.md for UI state>

An aside, because the agents should be state machines too


Thoughts

Similar Articles