Almost every React data-fetch starts the same way:
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
Three booleans. That's 2³ = 8 combinations, and most of them are nonsense: isLoading && isError? data present while isLoading is true? An error with stale data still showing? Every render has to defensively guard against states that shouldn't exist — and the bugs live in the combinations you forgot to guard.
So I built an interactive demo that models the same fetch as a finite state machine with useReducer, where those impossible states literally can't be represented.
▶ Live demo: https://reducer-state-machine.vercel.app/
Source: https://github.com/dev48v/reducer-state-machine
One status instead of three booleans
type State =
| { status: "idle" }
| { status: "loading"; attempt: number }
| { status: "success"; data: string[]; attempt: number }
| { status: "error"; error: string; attempt: number };
There's exactly one status at a time, and each variant carries only the data that state can have — data exists only in success, error only in error. Eight fuzzy boolean combinations collapse into four crisp ones. isLoading && isError isn't a bug you guard against; it's a state that can't be typed.
The reducer branches on the state, not the action
This is the part people get backwards. The outer switch is on state.status, and only then do you look at the action:
function reducer(state: State, action: Action): State {
switch (state.status) {
case "idle":
if (action.type === "FETCH") return { status: "loading", attempt: 1 };
break;
case "loading":
if (action.type === "RESOLVE") return { status: "success", data: action.data, attempt: state.attempt };
if (action.type === "REJECT") return { status: "error", error: action.error, attempt: state.attempt };
if (action.type === "CANCEL") return { status: "idle" };
break;
case "success":
if (action.type === "FETCH") return { status: "loading", attempt: state.attempt + 1 };
break;
case "error":
if (action.type === "RETRY") return { status: "loading", attempt: state.attempt + 1 };
break;
}
return state; // ← the important line
}
That final return state is the whole safety net. A RESOLVE dispatched while idle, a RETRY while loading — anything that isn't a valid transition from the current state — just returns the state unchanged. No corruption, no half-updated combination. In the demo, those show up in the dispatch log as ⊘ ignored.
What the demo makes visible
- The four states as chips, the active one lit, with the UI that state actually renders.
- Action buttons that enable only the actions valid from the current state — the machine's contract, made clickable. You can't
RESOLVEfromidlebecause there's no such edge. - The full transition table (
idle —FETCH→ loading,loading —REJECT→ error, …) with the current state's edges highlighted and the one just taken flashing. - A simulate a real request button that drives it like an effect would:
FETCH, then a delayed randomRESOLVE/REJECT.
When to reach for this
You don't need a reducer for a single boolean. But the moment a component has more than ~two related pieces of state that change together — a fetch, a multi-step form, a media player, a checkout — modeling it as switch (state.status) instead of a pile of useState booleans pays off immediately: fewer impossible states, transitions you can read in one place, and a reducer you can unit-test as a pure function. This is also exactly the mental model libraries like XState formalize; you can get most of the value with plain useReducer first.
Drive the machine in the demo and watch it refuse the invalid moves. If it made state machines click, a star helps others find it: https://github.com/dev48v/reducer-state-machine
Top comments (0)