DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on Originally published at github.com

I built a demo that models a fetch as a useReducer state machine — so impossible states can't happen

Almost every React data-fetch starts the same way:

const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 RESOLVE from idle because 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 random RESOLVE/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)