Siddhant DevalAuthor
Senior Full-Stack Engineer·Aug 27, 2026·12 min read
Local State: useState, useReducer & the Finite State Machine Pattern
State that only one component needs has no business living anywhere else. This article covers the full local state toolkit — from useState snapshots and immutable updates, through useReducer as a pure function contract, to Finite State Machines that make impossible UI states structurally unrepresentable.
Technical Series
Frontend State Architecture
Part 2 of 8
Local State: useState, useReducer & the Finite State Machine Pattern
The second pillar of senior-level state thinking is colocate by default. State that only one component needs has no business living in a global store, a parent's
useState, or a Context provider. The instinct to hoist state "just in case" is one of the most common sources of unnecessary complexity in React codebases.This article covers the full local state toolkit — when each tool applies, where each one breaks down, and how the Finite State Machine pattern eliminates an entire category of UI bugs by making impossible states structurally unrepresentable.
1. useState: A Snapshot, Not a Live Reference
The mental model most developers carry for
useState is close but subtly wrong: they think of it as a mutable variable React watches. The correct model is: useState gives you a snapshot of state at the moment of this render.typescript
countis a constant within this render invocation. It will never change mid-render.setCountschedules a re-render with the new value. It does not mutatecountin-place.- After the next render, you get a new
countconstant with the updated value.
1.1 The Stale Closure Trap
typescript
Always use the functional form
setState(prev => next) when the new state depends on the previous state, especially inside event handlers that call setState multiple times.
Expand
1.2 Lazy Initialization
If the initial state requires an expensive computation, pass a function to
useState — it's called only once, on mount:typescript
2. Managing Complex Objects Immutably
useState works fine for primitives. When the state shape becomes an object, immutability discipline matters:typescript
Performance / Safety Warning
When nested spreads reach three or four levels deep, the code is signalling a schema problem. Either flatten the state shape, split it into multiple
useState calls (one per logical group), or move to useReducer with Immer.3. useReducer: The Pure Function Contract
useReducer is the right tool when:- Multiple state fields must change together atomically.
- The next state depends on the previous state in a non-trivial way.
- You want the state transition logic decoupled from the component (the reducer is a pure function, easily unit-tested).
typescript
Mental Model Check
A reducer is a pure state machine transition function: given the current state and an event (action), return the next state. It has no side effects, no async operations, no DOM access. This purity is what makes it trivially unit-testable — no mocking required, just call the function with inputs and assert on the output.
4. Finite State Machines: Making Impossible States Impossible
This is the most high-leverage pattern in local state architecture. Consider a standard data-fetching component:
typescript
This representation has illegal states that are structurally possible but semantically impossible:
isLoading | isError | data | Valid? |
|---|---|---|---|
true | false | null | ✅ Loading |
false | false | User | ✅ Success |
false | true | null | ✅ Error |
true | true | null | ❌ Impossible — loading AND error? |
false | false | null | ❌ Ambiguous — idle or never fetched? |
true | false | User | ❌ Impossible — loading AND has data? |
The fix is a Finite State Machine with an explicit
status discriminant:
Expand
typescript
Usage with TypeScript exhaustive narrowing:
tsx
Crucial Requirement
With the FSM pattern, TypeScript's control flow narrows the type inside each case. In the
'success' branch, state.data is guaranteed to be User — not User | undefined. There is no optional chaining needed; the type system enforces the invariant.5. useRef: The Escape Hatch
useRef returns a mutable container whose .current property persists across renders without triggering a re-render when changed. There are exactly three legitimate use cases:5.1 Stable DOM Node Reference
tsx
5.2 Storing Mutable Values That Must Not Trigger Re-renders
typescript
5.3 Tracking the Previous Value
typescript
Performance / Safety Warning
Using
useRef to store values that should trigger re-renders when they change is a recognised anti-pattern. If you find yourself writing ref.current = newValue and then manually triggering a re-render somewhere else, you should be using useState instead. useRef is an escape hatch for values that intentionally live outside React's rendering model.6. Choosing the Right Local State Tool
| Scenario | Tool |
|---|---|
| Single boolean, number, or string | useState |
| Multiple independent simple values | Multiple useState calls |
| Object with fields that change independently | Multiple useState calls |
| Object with fields that must change atomically | useReducer |
| Complex async lifecycle (idle/loading/success/error) | useReducer + FSM |
| DOM node reference | useRef |
| Timer ID, WebSocket instance, scroll position tracker | useRef |
| Value that changes but should not cause re-render | useRef |
| State shared between siblings | Lift to parent (Part 3) |
| State used by many components across the tree | Context or global store (Part 3) |
7. References
Research & Synthesis Note
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.
#React#useState#useReducer#useRef#Finite State Machine#State Management#Immutability