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.
I have written this component in every React project I have started.
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.
Group Related State by Concern
The first fix: combine related fields. query, category, and sortBy are always updated together -- they describe one thing: the current search parameters.
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:
// 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:
| isLoading | isError | isSuccess | Valid? |
|---|---|---|---|
| false | false | false | Yes -- idle |
| true | false | false | Yes -- loading |
| false | false | true | Yes -- success |
| false | true | false | Yes -- error |
| true | true | false | Bug |
| true | false | true | Bug |
| false | true | true | Bug |
| true | true | true | Bug |
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.
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:
// 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.
// 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.
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'.
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} />;
}
// ...
} 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:
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:
// 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
- 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. - 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.
- Type states go further: co-locate status with the data that belongs to it. A discriminated union ensures
resultsonly exists in the success state andmessageonly exists in the error state -- enforced by the compiler, not by hope.
Further Reading and Watching
- You Might Not Need an Effect -- React Docs: Includes the canonical example of replacing multiple boolean flags with a single status string, directly applying the finite state pattern.
- Making Impossible States Impossible -- Richard Feldman, elm-conf 2016: The talk that popularized type states in frontend development. The TypeScript discriminated union pattern is the direct translation of his Elm examples. Note: verify this YouTube link before publishing.
Practice what you just read.
Keep reading