Best Practices for State Optimization in React
Events as the source of truth, pure functions, immutability, framework-independent logic -- five principles that keep your state management sane as a codebase grows.
Before you pick a pattern, you need principles. Patterns are just tools. Principles are what tell you which tool fits the problem.
These five ideas shaped how I think about state management. They apply whether you use useState, useReducer, Zustand, XState, or anything else. The underlying thinking stays the same.
Events Are the Real Source of Truth
Think about how a version control system works. Git does not store your current files. It stores commits -- events -- and your current files are what you get when you replay those events from the beginning.
The events are the source of truth. The working tree is derived.
State management works the same way.
A bug tracker has events: TASK_CREATED, ASSIGNED_TO, STATUS_CHANGED, COMMENT_ADDED. The current state of a task -- who owns it, what status it is in, what was last said -- is what you get when you reduce those events.
ExpandEvents array of TASK_CREATED, ASSIGNED_TO, STATUS_CHANGED, COMMENT_ADDED passing through a reduce function to produce current task state
This mental model matters for two reasons.
First, it explains why useReducer feels cleaner than useState for complex logic. A reducer is literally a function that takes a state and an event and returns the next state. It is the reduce step made explicit.
Second, it explains why useEffect dependency arrays lose information. When an effect runs because something in its dependency array changed, you do not know which thing changed, why it changed, or what action caused it.
Events give you intent, timing, and a trail. Dependency arrays give you none of that.
// Compare these two models:
// Dependency array: "something changed, no idea what or why"
useEffect(() => {
recalculate();
}, [a, b, c]);
// Event: "a specific thing happened, with context"
dispatch({ type: 'FILTERS_APPLIED', filters: { a, b, c } });If your state starts to feel like a tangled web of effects firing each other, model the events first. Everything becomes easier once you know what actually caused a change.
Pure Functions for Application Logic
A pure function takes inputs, produces an output, and does nothing else. No side effects, no hidden dependencies. Same input always produces the same output.
This is the right shape for application logic.
type SearchState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; results: Product[] }
| { status: 'error'; message: string };
type SearchEvent =
| { type: 'SEARCH_STARTED' }
| { type: 'RESULTS_RECEIVED'; results: Product[] }
| { type: 'SEARCH_FAILED'; message: string };
function searchReducer(state: SearchState, event: SearchEvent): SearchState {
switch (event.type) {
case 'SEARCH_STARTED':
return { status: 'loading' };
case 'RESULTS_RECEIVED':
return { status: 'success', results: event.results };
case 'SEARCH_FAILED':
return { status: 'error', message: event.message };
default:
return state;
}
}This function is trivial to test. You pass in a state, you pass in an event, you assert the output. No mocking, no component setup, no async -- just a function call.
Pure functions are also composable. You can combine them, wrap them, memoize them reliably. When a function has no hidden state, memoization is safe: the same input always produces the same output.
Write as if the Framework Will Change
This sounds extreme. It is not a prediction -- it is a discipline.
When you mix business logic with React-specific code, the logic becomes hard to test and even harder to move.
An onClick handler that both processes user input and calls setXxx is doing two things at once. One of them is React. One of them is not.
The goal: business rules live in plain functions that happen to be called by React, not in React hooks that happen to contain business logic.
A reducer like searchReducer above lives outside any component. It has no imports from React. You can run it in a Node.js test, a Storybook story, or a different framework entirely.
The React integration is just useReducer(searchReducer, initialState) -- a single line.
This separation pays off when components grow complex, when logic needs to be shared, and when you want reliable unit tests.
State Machines Prevent Impossible States
The most common source of subtle frontend bugs: two boolean flags that should be mutually exclusive but are not encoded that way.
// These can all be true simultaneously -- and occasionally will be
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);Three booleans create eight possible combinations. Only three of those combinations are valid. The other five are bugs waiting to be triggered by a race condition, a missed setIsLoading(false) call, or a component unmounting at the wrong moment.
A finite state collapses this to exactly one value at a time:
type SearchStatus = 'idle' | 'loading' | 'success' | 'error';Four states. Exactly one active. No impossible combinations.
The status === 'loading' && status === 'error' condition is not just unlikely -- it is impossible to represent in the type system. That is a much stronger guarantee than "I think I set these flags correctly."
State machines formalize this. But even without a library, the principle holds: if two values are always mutually exclusive, they should be one value.
Declarative Side Effects
The last principle is a teaser for a later post in this series. It is worth naming here because it follows directly from the previous four.
When state transitions trigger side effects -- an API call, a navigation, a timer -- the imperative approach is to run the effect inside the handler. The event happens, the state updates, the side effect fires.
The declarative alternative: the transition returns what effects should run without running them. The system executes the effects separately.
This is the architecture that ELM popularised, and that XState builds on. The pure function describes what happens. The runtime decides when and how.
The upside: you can test the full behavior of a complex flow -- including which effects it triggers -- without running a single async operation. The function returns a description of the side effect, not the side effect itself.
This becomes concrete when we get to useReducer for complex state.
The Essentials
- Events are the real source of truth. Any change in your app was caused by something specific. Name that thing. Model it. A named event carries intent, timing, and history that a bare state value never can.
- Pure functions are the right shape for application logic. They are deterministic, testable, and composable. A reducer that takes state + event → next state is always easier to reason about than logic scattered across
useEffecthooks. - Mutually exclusive values should be one value. Three boolean flags have eight combinations. A status string has four. One has impossible states. The other does not.
Further Reading and Watching
- State Machines in React -- Stately Blog: A practical walkthrough of applying the event-driven model to React components, from the XState team.
- Making Impossible States Impossible -- Richard Feldman, elm-conf 2016: The talk that introduced this idea to the frontend community. TypeScript discriminated unions solve the same problem he demonstrates with Elm types. Note: verify this YouTube link before publishing.
Keep reading