Most React state bugs I review are not logic errors. They are states the code allowed that the product never intended.
The shape that causes the bug
type State = {
isLoading: boolean;
error: Error | null;
data: User | null;
};Three fields, eight combinations. What does { isLoading: true, error: someError, data: someUser } mean? Nobody knows, which is exactly why the spinner sometimes renders on top of an error message.
Make the invalid states unrepresentable
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: Error };Four states, all meaningful. The compiler now enforces that data exists only when it can exist:
function UserView({ state }: { state: State }) {
switch (state.status) {
case "idle":
return <Placeholder />;
case "loading":
return <Skeleton />;
case "error":
return <ErrorMessage error={state.error} />;
case "success":
return <Profile user={state.data} />;
}
}No state.data!, no optional chaining, no defensive checks.
Exhaustiveness is the real prize
Add a "refreshing" variant and every switch that forgot to handle it fails to compile — provided you ask for that check:
function assertNever(value: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}
default:
return assertNever(state);That never parameter is the whole trick. If state can still be something, it will not be assignable to never, and you get an error at the call site.
Where else this pays off
- API responses —
{ ok: true; data: T } | { ok: false; error: string }beats a nullabledatawith a nullableerror. - Form submission —
idle | submitting | submitted | failed. - Component props — a button that is either
{ href: string }or{ onClick: () => void }, never both and never neither.
type ButtonProps =
| { href: string; onClick?: never }
| { onClick: () => void; href?: never };A boolean says "yes or no". A union says "here is the complete list of things that can be true". The second is almost always what you meant.