Definition
The state modeling ladder is a three-rung order for deciding "should I model this state as a union?" You check from the top, and if an earlier rung settles it, you don't go further down.
- If you can derive it, don't store it. Compute it from the source.
- If a library already owns the union, consume it as-is. Don't copy it.
- Only the real client state that's left becomes a union plus intent functions.
The point isn't "how do I write a good union" — it's asking "do I need a union at all?" first.
Why It Matters
When people learn state modeling, most of them start at rung 3. You write type State = { status: 'idle' } | { status: 'loading' } | ... and feel pretty good about it. But the bugs actually show up at rungs 1 and 2.
Skip rung 1 and you get code like this.
const [items, setItems] = useState<Item[]>([]);
const [itemCount, setItemCount] = useState(0); // the same fact as items.length
itemCount is a value you can compute from items. Two places own the same fact, so sooner or later only one of them gets updated. No amount of careful typing prevents that drift.
Skip rung 2 and you get code like this.
const { data, isLoading, isError } = useQuery(...);
const [state, setState] = useState<'idle' | 'loading' | 'done' | 'failed'>('idle');
useEffect(() => {
if (isLoading) setState('loading');
else if (isError) setState('failed');
else if (data) setState('done');
}, [isLoading, isError, data]);
The query library already has a status union — and it handles the time axis based on the latest call, too. If you rebuild that with useState, the library knows when responses arrive out of order but your copy doesn't. You've created a second source of truth that trails one render behind.
How It Works
Rung 1 — If you can derive it, don't store it
There's only one question to ask. "Can I compute this value from another value?"
// ❌ two places own the same fact
const [items, setItems] = useState<Item[]>([]);
const [hasItems, setHasItems] = useState(false);
// ✅ only one owner
const [items, setItems] = useState<Item[]>([]);
const hasItems = items.length > 0;
If the cost of computing worries you, that's when you reach for useMemo. Storing isn't a performance tool — it's a last resort.
Rung 2 — Use the union the library owns
The lifecycle of data coming from the server is usually already modeled. TanStack Query's status and fetchStatus, and a mutation's isPending / isSuccess / isError, are exactly that.
// ❌ repackaging the same state into a new union
type NextPageState = 'can-load' | 'loading' | 'exhausted';
// ✅ consume the library's union directly
const { status, data, fetchNextPage, hasNextPage } = useInfiniteQuery(...);
Two rules come attached here.
Loading and failure belong at a boundary by default, not in component branches. For a first fetch that always runs, lift it with useSuspenseQuery + a local <Suspense> + an Error Boundary, so the component body has no branching at all. Keep branches only for the leftovers you can't lift — conditional queries, cancellation constraints, and the like.
Shared UI doesn't need to know the whole lifecycle. If all you need is "can we load another page," don't pass a state union down — treat the existence of the callback itself as the capability.
// ❌ a shared component has to know someone else's lifecycle
type Props = { pageState: NextPageState };
// ✅ if the function is there you can do it, if not you can't
type Props = { onLoadMore?: () => void };
Rung 3 — Only the leftover client state becomes a union
If you made it this far, now you build the real union. Just hold to two things.
Don't hand raw setters out. The hook returns functions that express domain intent, not setState.
// ❌ anyone can set any state
function useCheckout() {
const [state, setState] = useState<CheckoutState>(...);
return { state, setState };
}
// ✅ only the allowed transitions are exposed
function useCheckout() {
const [state, setState] = useState<CheckoutState>(...);
return { state, submit, reset, goBack };
}
A tagged-object union is only for when two or more members carry their own fields.
// ✅ tagged objects — only shipping has fieldErrors, only review has quote
type CheckoutState =
| { status: "cart"; items: CartItem[] }
| { status: "shipping"; address: Address; fieldErrors: FieldErrors }
| { status: "review"; quote: Quote; agreed: boolean };
// ✅ literal union — no attached data. Wrapping it blocks nothing new
type PaymentBadge = "unpaid" | "paid" | "refunded";
Needing a label map is not a reason to build tagged objects. satisfies Record<PaymentBadge, string> works on a literal union just fine.
In Practice
State is data, actions are siblings
This is what people get wrong most often at rung 3. Don't put functions inside a state union.
// ❌ don't — you get stale closures and fake retries at the same time
type DetailState =
| { status: "loading" }
| { status: "failure"; retry: () => void };
// ✅ state is data, actions are siblings
type DetailState =
| { status: "loading" }
| { status: "failure"; reason: LoadFailure };
function useDetail(id: DetailId): { state: DetailState; retry: () => void };
There are two reasons.
First, a stored function is pinned to the closure of the render that created it. Even after props or parameters change, it keeps capturing the old values. The state updates, but the function inside it is looking at the past.
Second, you end up filling unusable states with no-op actions. If you drop in retry: () => undefined just to satisfy the type, the UI receives the false claim that "you can retry." The button shows up, and pressing it does nothing.
A state machine is not the default
Reducers, transition tables, and XState are for flows where violating the order is itself a domain error. Payments, multi-step submissions, optimistic rollbacks — that kind of thing.
Loading, success, and failure for a simple fetch end at rung 2. Even if you go down to rung 3, all you need is one union and a few intent functions. If you build an Event union and transition functions just because "we have a state model doc," all you get is more machines that prevent nothing.
When to split out invalid states
Things like an ID that failed to parse, or a missing route parameter. The test is "are the screen and the recovery path actually different?"
If they're the same, fold it into the existing failure state. If they're different, split it — but fill in each one's own fields and actions. If the requirements don't draw the distinction, don't invent it; ask.
Tradeoffs
The higher up the ladder you stop, the less code you write but the less expressive it gets. Derived computations run again on every render, and the library's union may not use the names you'd pick. So there's a constant temptation: "it'd be so much easier to just make my own union."
The standard that beats that temptation is is there exactly one owner? A copy you made for convenience will eventually diverge from the original, and the debugging cost at that moment is far bigger than the time you saved up front.
On the other hand, following the ladder dogmatically can leave you jamming things into a library API where they don't fit. When there's a genuine disqualifier — conditional queries, placeholders, cancellation constraints — going down a rung is the right call. Just write the reason down. If you can't write it down, it's usually not a disqualifier, just familiarity.
When Not to Use It
- Prototype exploration. If what you're building isn't decided yet, locking down the state shape just gets in the way.
- A component with only one piece of state. Wrapping something a single boolean covers in a union only makes it harder to read. The test is "do the members carry different data?"
- A plain form built without a library. Putting a rung-3 union on a form that only has field values and errors is over-design.
Common Mistakes
- Storing a value you could derive. The most common and quietest failure. The test: "if I delete this value, can I compute it from another one?"
- Copying query state into a local machine. You get a second source of truth that trails one render behind.
- Putting actions inside a state union. Stale closures and fake actions, both at once.
- Expressing one flow with several booleans. An
isLoading+isErrorcombination lets the type system allow the impossible state "loading and errored." Replace it with a singlestatusliteral. - Wrapping in a tagged object when there are no own fields. Wrapping it as
{ kind: 'paid' }only adds the cost of unwrapping.kindat every call site, and blocks no new incorrect code. - Branching on the first fetch's loading and error inside the component. If you could have lifted it to a boundary and didn't, that same branch gets duplicated in every component.
Related Concepts
- exhaustiveness-enforcement — making the compiler check that you've handled every case of the union you built
- false-type-contracts — avoiding types that promise more than the runtime delivers
- react-query-invalidate-vs-staletime — the refresh policy for the query state you consume at rung 2
- typescript-environment-contract — the compiler settings these rules need in order to actually be enforced