Optimizing State Management in React
📂 Exercise:
exercise-finite/page.tsx
Core Principles of State Management 🎯
1. Events Are the Real Source of Truth
Think about how a bank tracks your balance. It doesn't store a single "current balance" object it maintains a ledger of events: deposits, withdrawals, transfers. Your balance at any point is derived by reducing that event log.
The same model applies to application state. Every state change in your app is caused by an event a user action, a network response, a timer. By thinking in terms of events:
- You capture intent (what happened), not just the resulting value
- You preserve timing and ordering of changes
- You get a natural audit log for debugging
This is also why useEffect dependency arrays are limiting they tell you a value changed, but not what caused it or why.
Whether you use events explicitly or not, every state change in your app is conceptually driven by an event. Making that explicit improves debuggability and predictability.
2. Pure Functions and Immutability
Keep your core application logic in pure functions functions that given the same input always return the same output, with no side effects.
Benefits:
- Deterministic behavior easy to reason about, easy to test
- Composable pure functions combine predictably
- Memoization-safe caching results of pure functions is reliable since outputs are consistent with inputs
The pattern: represent your state transitions as (currentState, event) => nextState. This works in any framework — React, Vue, Angular — because it's just a function.
3. Framework-Independent Logic
Write business logic as if the framework could change under you. Avoid tightly coupling app logic to React-specific patterns like useEffect or component lifecycle methods.
The practical benefit: logic extracted into pure functions is trivially testable without mounting components, mocking hooks, or setting up a render environment.
4. State Machines for Modeling
State machines enforce two guarantees: no impossible states and no impossible transitions.
Example of an impossible state:
// ❌ These two booleans can both be true simultaneously
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
// isLoading=true AND isError=true is technically possible — but meaninglessA common symptom of unmodeled states: the flash of empty state. A user clicks a search result and briefly sees "No results found" before data loads. Tests pass, the app works but the user sees an inconsistent state. Finite states eliminate this class of bug.
5. Declarative Side Effects
Separate state transitions (pure logic) from effect execution (impure). Instead of triggering effects inside transition logic, declare what effects should run as part of the new state then execute them separately.
This makes it easy to:
- Test that an effect would be triggered without actually running it
- Reason about what happens when entering a state
- Avoid accidental side-effect coupling in reducers
Combining Related State 💡
When multiple useState calls represent a single logical concern, consolidate them into one state object.
// ❌ Fragmented state: four separate useState calls
const [coffeeType, setCoffeeType] = useState('cappuccino');
const [size, setSize] = useState('medium');
const [sugar, setSugar] = useState(false);
const [milk, setMilk] = useState(true);
// ✅ Consolidated: one object, one concern
const [coffee, setCoffee] = useState({
type: 'cappuccino',
size: 'medium',
sugar: false,
milk: true,
});Updating Nested State: Always Use the Previous Value
When updating a single field in a state object, always use the functional update form to avoid stale closure bugs:
// ❌ Risk of stale closure — `coffee` might be outdated
setCoffee({ ...coffee, type: e.target.value });
// ✅ Correct — `prev` is always the latest value
setCoffee(prev => ({ ...prev, type: e.target.value }));Why stale closures happen: If setCoffee is called inside a memoized callback, a useEffect, or an event handler that was created before the last render, it captures an old version of coffee. The functional form bypasses this by receiving the current state directly from React.
Don't group all state into one object. Group by concern. If changing one field should always trigger re-renders in the same consumers, those fields belong together.
Finite States: Replace Boolean Flags 🎯
The classic boolean flag problem:
// ❌ Multiple booleans representing one logical state
const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
// These three are mutually exclusive but nothing enforces thatReplace mutually exclusive booleans with a single status string enum:
// ✅ One status, four explicit states
type Status = 'idle' | 'submitting' | 'error' | 'success';
const [status, setStatus] = useState<Status>('idle');
// Transitions become explicit and atomic
setStatus('submitting'); // can't also be 'success' at the same time
setStatus('success');
setStatus('error');You can also derive the boolean flags from status if your existing code depends on them avoiding a large refactor while still getting the benefits of a single source of truth:
// Derived booleans — no longer stored state
const isSubmitting = status === 'submitting';
const isError = status === 'error';
const isSuccess = status === 'success';Type States: TypeScript-Enforced State Consistency
Finite states tell you what state you're in. Type states go further they enforce what data is available in each state at the type level.
The Problem
const [status, setStatus] = useState<Status>('idle');
const [receipt, setReceipt] = useState<string | null>(null);
// When status === 'success', receipt is guaranteed to exist
// But TypeScript doesn't know that — you need a non-null assertion
if (status === 'success') {
console.log(receipt!.total); // ← non-null assertion needed
}The Fix: Discriminated Unions
type CoffeeOrder =
| { status: 'idle'; receipt: null }
| { status: 'submitting'; receipt: null }
| { status: 'error'; receipt: null; errorMessage: string }
| { status: 'success'; receipt: { total: number } };
const [order, setOrder] = useState<CoffeeOrder>({ status: 'idle', receipt: null });Now TypeScript enforces consistency in both directions:
// Setting state — TypeScript validates the shape
setOrder({ status: 'success', receipt: { total: 42 } }); // ✅
setOrder({ status: 'success' }); // ❌ Error: receipt is missing
// Reading state — no non-null assertions needed
if (order.status === 'success') {
console.log(order.receipt.total); // ✅ TypeScript knows receipt exists here
}
if (order.status === 'error') {
console.log(order.receipt.total); // ❌ Error: receipt is null in error state
}Combining Common Fields with Intersection Types
When multiple states share common fields (like form inputs), use an intersection type to avoid repeating them:
type FlightFormBase = {
destination: string;
departure: string;
arrival: string;
passengers: number;
};
type FlightData = FlightFormBase & (
| { status: 'idle'; flightOptions: null; error: null }
| { status: 'submitting'; flightOptions: null; error: null }
| { status: 'error'; flightOptions: null; error: string }
| { status: 'success'; flightOptions: Flight[]; error: null }
);
const [flightData, setFlightData] = useState<FlightData>({
destination: '',
departure: '',
arrival: '',
passengers: 1,
status: 'idle',
flightOptions: null,
error: null,
});On success, a single setFlightData call sets the status and the results atomically — no separate setFlightOptions, no risk of mismatched state:
setFlightData(prev => ({
...prev,
status: 'success',
flightOptions: results,
error: null,
}));Exercise: Flight Booking Form Refactor
The exercise-finite covers both patterns. The refactor steps:
- Combine related fields — group
destination,departure,arrival,passengersinto a singleflightDataobject - Replace boolean flags — remove
isLoading,isSuccess,isErrorand introduce astatusstring enum - Derive booleans if needed — keep the derived forms (
const isSubmitting = status === 'submitting') to avoid breaking existing render logic - Optionally add type states — use a discriminated union to enforce that
flightOptionsonly exists whenstatus === 'success'
Use the strangler fig pattern when refactoring: keep the old and new code side by side, verify the new path works, then delete the old. Don't delete and pray.
On useMemo and useCallback
A few practical guidelines that came up during this section:
- Don't memoize by default.
useMemoanduseCallbacktrade memory for computation. Using them everywhere can actually degrade performance. - Memoize only when you have evidence of a problem — a measured render performance issue, not a theoretical one.
- The React Compiler (stabilized in React 19) handles many memoization cases automatically — but only if your code follows idiomatic React patterns. Intentionally incomplete dependency arrays (the "it works, don't touch it"
eslint-disablepattern) will break compiler optimization.
If you're disabling the exhaustive-deps ESLint rule because adding all dependencies would break the behavior, that's a signal the logic belongs in a reducer or an event handler — not a
useEffect.