Finite States and Type States in React

Managing four boolean flags to represent loading, error, success, and idle is a trap. Finite states and discriminated unions collapse invalid combinations before they can happen.

June 7, 20267 min read2 / 2

I have written this component in every React project I have started.

TSX
function ProductSearch() { const [query, setQuery] = useState(''); const [category, setCategory] = useState('all'); const [sortBy, setSortBy] = useState('relevance'); const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); const [isSuccess, setIsSuccess] = useState(false); const [results, setResults] = useState<Product[]>([]); // ... }

Seven useState calls. Four of them are tracking a single async operation that can only be in one state at a time. This looks organized because each piece of data has its own variable.

It is not organized. It is fragile.

The first fix: combine related fields. query, category, and sortBy are always updated together -- they describe one thing: the current search parameters.

TSX
function ProductSearch() { const [searchParams, setSearchParams] = useState({ query: '', category: 'all', sortBy: 'relevance', }); // ... }

When updating a single field, always use the functional update form to avoid stale closure bugs:

TSX
// Do this -- uses latest state regardless of when the handler was created setSearchParams(prev => ({ ...prev, query: e.target.value })); // Not this -- 'searchParams' may be a stale closure in memoized or effect contexts setSearchParams({ ...searchParams, query: e.target.value });

The closure problem is subtle. If this handler sits inside a useMemo or a useEffect, the captured searchParams might be from a previous render.

The functional form always receives the latest value. Use it as a habit.

The grouping rule: combine state when the values always update together or always read together. Not blindly -- a component should not have one giant useState object. Group by concern, not by proximity.

The Boolean Flag Problem

Back to the async state. Three flags, eight combinations:

isLoadingisErrorisSuccessValid?
falsefalsefalseYes -- idle
truefalsefalseYes -- loading
falsefalsetrueYes -- success
falsetruefalseYes -- error
truetruefalseBug
truefalsetrueBug
falsetruetrueBug
truetruetrueBug

Four bugs are representable in the type system. You are hoping your code never produces them.

Hope is not a type system.

Finite States: One Value for One Thing

The first improvement: replace the three booleans with a single status string.

TSX
type SearchStatus = 'idle' | 'loading' | 'success' | 'error'; function ProductSearch() { const [searchParams, setSearchParams] = useState({ query: '', category: 'all', sortBy: 'relevance' }); const [status, setStatus] = useState<SearchStatus>('idle'); const [results, setResults] = useState<Product[]>([]); async function handleSearch() { setStatus('loading'); try { const data = await fetchProducts(searchParams); setResults(data); setStatus('success'); } catch { setStatus('error'); } } // ... }

Four valid values. Exactly one active at any time. status === 'loading' and status === 'success' cannot both be true simultaneously -- they are the same variable.

If existing code relies on the boolean flags, derive them instead of removing them immediately:

TSX
// Derived -- backward compatible, zero extra state const isLoading = status === 'loading'; const isError = status === 'error'; const isSuccess = status === 'success';

This is the Strangler Fig approach to refactoring: keep the old interface alive by deriving it from the new model, verify everything works, then delete the derived flags one by one as you update the callers.

Type States: Status-Specific Data

Finite states collapse the boolean problem. But there is a second problem: data that only exists in certain states.

TSX
// After a successful search, we have results. // After a failed search, we have an error message. // During loading, we have neither. // But all of these live separately, disconnected from status: const [results, setResults] = useState<Product[] | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);

When status === 'success', you know results exists. But TypeScript does not know that -- results is typed as Product[] | null regardless of status. You end up writing results! or results as Product[] to satisfy the compiler.

Type states solve this by co-locating status with the data that belongs to it, using a discriminated union.

TypeScript
type SearchState = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; results: Product[] } | { status: 'error'; message: string };

Now the data structure enforces what the state diagram says: results only exists when status === 'success', and message only exists when status === 'error'.

TSX
function ProductSearch() { const [searchParams, setSearchParams] = useState({ query: '', category: 'all', sortBy: 'relevance' }); const [searchState, setSearchState] = useState<SearchState>({ status: 'idle' }); async function handleSearch() { setSearchState({ status: 'loading' }); try { const results = await fetchProducts(searchParams); setSearchState({ status: 'success', results }); } catch (err) { setSearchState({ status: 'error', message: err instanceof Error ? err.message : 'Unknown error' }); } } // In the render: if (searchState.status === 'success') { // TypeScript knows searchState.results exists here -- no ! needed return <ResultsList items={searchState.results} />; } if (searchState.status === 'error') { return <ErrorMessage text={searchState.message} />; } // ... }

Three-column comparison: three boolean flags with 8 combinations (4 invalid), finite state string enum with 4 valid values, and type states discriminated union with status-specific data enforced by TypeScript ExpandThree-column comparison: three boolean flags with 8 combinations (4 invalid), finite state string enum with 4 valid values, and type states discriminated union with status-specific data enforced by TypeScript

The TypeScript compiler now catches errors at the source. If you try to set { status: 'success' } without including results, TypeScript rejects it. The invariant is enforced at every call site, not just the render.

When There Is Shared Data

Sometimes a component has fields that exist across all states plus status-specific fields. The intersection type pattern handles this:

TypeScript
type SharedSearchData = { query: string; category: string; sortBy: string; }; type SearchState = SharedSearchData & ( | { status: 'idle' } | { status: 'loading' } | { status: 'success'; results: Product[] } | { status: 'error'; message: string } );

query, category, and sortBy are always present. The status-specific fields (results, message) only appear in the relevant variants. Setting the state in one call keeps everything consistent:

TSX
// Transition to success -- shared data preserved, status-specific data added setSearchState(prev => ({ ...prev, status: 'success', results: data }));

A Note on useMemo and useCallback

These two hooks come up naturally when grouping state or deriving values. The trade-off is real: memoization is not free.

It trades CPU time for memory. Wrapping every computed value in useMemo can actually make a component slower.

Measure before memoizing. The React Compiler (landing in React 19) will handle most of this automatically -- but only for components written idiomatically. Long dependency arrays with intentionally missing dependencies will not benefit from it.

This is one reason keeping logic in pure functions outside components pays off: functions you can reason about clearly are functions the compiler can optimize reliably.

The patterns in this post connect directly to the next chapter, where useReducer for complex state provides a natural home for the event-driven reducer model.

The Essentials

  1. Group state by concern, not by proximity. Related fields that always update together belong in one object. Use the functional update form setState(prev => ({ ...prev, field: value })) to avoid stale closure bugs.
  2. Three boolean flags for one async operation collapse to one status string. Finite states make impossible combinations unrepresentable. Derive boolean flags from status during incremental migration.
  3. Type states go further: co-locate status with the data that belongs to it. A discriminated union ensures results only exists in the success state and message only exists in the error state -- enforced by the compiler, not by hope.

Further Reading and Watching

Practice what you just read.

Boolean Flags vs. Finite States
1 exercise