Optimizing State Management in React

7 min read

📂 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:

TSX
// ❌ 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 meaningless

A 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

When multiple useState calls represent a single logical concern, consolidate them into one state object.

TSX
// ❌ 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:

TSX
// ❌ 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:

TSX
// ❌ 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 that

Replace mutually exclusive booleans with a single status string enum:

TSX
// ✅ 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:

TSX
// 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

TSX
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

TSX
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:

TSX
// 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:

TSX
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:

TSX
setFlightData(prev => ({ ...prev, status: 'success', flightOptions: results, error: null, }));

Exercise: Flight Booking Form Refactor

The exercise-finite covers both patterns. The refactor steps:

  1. Combine related fields — group destination, departure, arrival, passengers into a single flightData object
  2. Replace boolean flags — remove isLoading, isSuccess, isError and introduce a status string enum
  3. Derive booleans if needed — keep the derived forms (const isSubmitting = status === 'submitting') to avoid breaking existing render logic
  4. Optionally add type states — use a discriminated union to enforce that flightOptions only exists when status === '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. useMemo and useCallback trade 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-disable pattern) 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.