Skip to main content

State Structure Patterns

What you'll learn

  • Why where state lives matters more than any memoization trick
  • Derived state: why computing during render beats mirroring into useState
  • The two blessed escapes when you genuinely must adjust state from props
  • Normalizing nested data so updates stop being tree-cloning exercises
  • The preserve/reset decision map, and the reducer + context pattern for shared state

Most "React performance problems" and most "React state bugs" are the same problem in disguise: state placed in the wrong shape or the wrong location. No amount of memo fixes state that lives too high, is duplicated, or mirrors something it shouldn't. This chapter is the architecture-level toolkit, six patterns that prevent the problems the rest of Part 8 treats.

Pattern A: Colocation: push state down until it's shared

State should live in the smallest component that actually needs it, and only rise when two components genuinely must share it.

Jargon: "colocation". Keeping state (and really any code) as close as possible to the place it's used, instead of hoisting it to a distant parent "just in case".

We saw the payoff in chapter 1: moving query from Page into SearchBox meant the expensive Chart stopped re-rendering entirely, no memo, no useMemo, no ongoing contract to maintain. Compare the two strategies on every future render:

  • Memo everything: pay a props compare on every render of every boundary, forever, and re-verify stability after every refactor.
  • Move state down: the renders simply don't happen. There's nothing to maintain.

The rule of thumb: when you notice yourself memoizing a whole subtree to protect it from one piece of state, ask whether that state could just move. Lift state only as high as the lowest common ancestor of its real consumers, not one level higher.

Pattern B: Derived state: never store what you can compute

If a value can be calculated from existing state or props, calculate it during render. Do not put it in useState.

The bug farm looks like this:

import { useEffect, useState } from 'react';

function Cart({ items }) {
const [total, setTotal] = useState(0);

// ❌ Mirroring a computation into state
useEffect(() => {
setTotal(items.reduce((sum, i) => sum + i.price, 0));
}, [items]);

return <p>Total: {total}</p>;
}

What happens, and why it's a bug farm:

  1. First render: total is 0, wrong, but it's what initial state says. The user (or a test) can see Total: 0 flash.
  2. Commit. The effect runs, calls setTotal, scheduling a second render.
  3. Second render finally shows the right total. Every items change: two renders, one visible wrong frame.
  4. Forget the dep array once, or add a quantity field you forget to include, and the mirror is permanently stale. This pattern manufactures stale-state bugs.

The fix deletes code:

function Cart({ items }) {
// ✅ Computed during render: always right, always in sync
const total = items.reduce((sum, i) => sum + i.price, 0);
return <p>Total: {total}</p>;
}

fullName from firstName + lastName, filteredList from list + query, isValid from form fields, all the same: compute in render. If the computation is genuinely expensive and the inputs rarely change, wrap it in useMemo, but that's a cache of the same computation, not a second copy of the data. One source of truth, always.

Pattern C: Mirroring props in state: and the two blessed escapes

The tempting anti-pattern:

function Editor({ draftText }) {
// ❌ Copies the prop into state once, then never tracks it
const [text, setText] = useState(draftText);

// ❌ The "fix" people add — a sync effect
useEffect(() => {
setText(draftText);
}, [draftText]);

return <textarea value={text} onChange={(e) => setText(e.target.value)} />;
}

What happens: the initial useState(draftText) freezes the first prop value forever (initial state is used once, then ignored, Part 3). The sync effect unfreezes it but doubles every prop change into two renders, and clobbers any local edits mid-flight if the parent re-sends the old value. You're fighting React with React.

Escape 1, uncontrolled with a key reset. When the real requirement is "a fresh editor per document," say so with a key:

function Editor({ initialText }) {
const [text, setText] = useState(initialText); // fine: initial value per mount
return <textarea value={text} onChange={(e) => setText(e.target.value)} />;
}

export default function DocPage({ docId, draftText }) {
// ✅ New key = unmount + remount = state resets, deliberately
return <Editor key={docId} initialText={draftText} />;
}

Keys control identity (Part 2): change the key and React throws the old instance away, state included. One line replaces the whole mirror-and-sync apparatus.

Escape 2, adjust state during render with a prev-comparison. When you must react to a prop change while keeping related local state:

import { useState } from 'react';

function Selection({ items }) {
const [selectedId, setSelectedId] = useState(null);
const [prevItems, setPrevItems] = useState(items);

// ✅ Adjust state DURING render — no effect, no extra commit
if (items !== prevItems) {
setPrevItems(items);
if (selectedId !== null && !items.some((i) => i.id === selectedId)) {
setSelectedId(null); // selection no longer exists — clear it
}
}

return <p>{selectedId === null ? 'Nothing selected' : `Selected ${selectedId}`}</p>;
}

Why calling setState during render is legal here:

  1. React sees a state update requested by the component currently rendering.
  2. Instead of committing this render, React immediately re-renders the same component with the new state, before anything touches the DOM.
  3. The second render sees items === prevItems, skips the adjustment block, and completes. One commit, no flash of stale UI, no effect round-trip.

Jargon: "render-phase update". A state update requested while a component is rendering. React restarts that component's render instantly, before commit. Only safe in this guarded compare-prev pattern, an unguarded setState during render is an infinite loop.

Pattern D: Normalize nested data

Deeply nested state makes every update a tree-cloning ritual:

// ❌ Nested: updating one comment means mapping two levels
const [post, setPost] = useState({
title: 'Hello',
comments: [
{ id: 1, text: 'Nice', likes: 2, replies: [{ id: 9, text: 'Agree' }] },
],
});

Instead, store entities by id plus arrays of ids, the same shape a database would use:

// ✅ Normalized: every entity lives in exactly one place
const [state, setState] = useState({
posts: { p1: { id: 'p1', title: 'Hello', commentIds: ['c1'] } },
comments: { c1: { id: 'c1', text: 'Nice', likes: 2, replyIds: ['r9'] } },
replies: { r9: { id: 'r9', text: 'Agree' } },
});

Now liking comment c1 is one shallow copy:

setState((s) => ({
...s,
comments: {
...s.comments,
c1: { ...s.comments.c1, likes: s.comments.c1.likes + 1 },
},
}));

No walking, no mapping levels you don't touch, and each entity has exactly one source of truth, so two views of the same comment can never disagree.

Pattern E: The preserve/reset decision map

React decides whether state survives a re-render using position and type (Part 2). Memorize this table, it predicts every "why did my input lose its text" bug:

What changed between rendersState of that subtree
Same position, same type, same keyPreserved
Same position, same type, key changedReset (old instance discarded)
Same position, different type (<div><section>)Reset (whole subtree)
Moved to a different position/parentReset
Removed (conditional &&, unmount)Destroyed
Wrapped in a new component at the same spotReset (type changed)

Two practical corollaries: {cond && <Input/>} destroys the input's state when cond flips (use hidden styling or Offscreen, Part 9, to preserve it), and defining a component inside another component creates a new type every render, resetting its subtree every single time. Never nest component definitions.

Pattern F: Reducer + context for shared state

When state is shared widely and updates are complex, the sweet spot before reaching for a library: one reducer at the top, split contexts to limit re-renders.

import { createContext, useContext, useReducer } from 'react';

const TasksStateContext = createContext(null);
const TasksDispatchContext = createContext(null);

function tasksReducer(tasks, action) {
switch (action.type) {
case 'added':
return [...tasks, { id: action.id, text: action.text, done: false }];
case 'toggled':
return tasks.map((t) =>
t.id === action.id ? { ...t, done: !t.done } : t
);
default:
throw new Error('Unknown action: ' + action.type);
}
}

export function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(tasksReducer, []);
return (
<TasksStateContext.Provider value={tasks}>
<TasksDispatchContext.Provider value={dispatch}>
{children}
</TasksDispatchContext.Provider>
</TasksStateContext.Provider>
);
}

export function useTasks() {
return useContext(TasksStateContext);
}
export function useTasksDispatch() {
return useContext(TasksDispatchContext);
}

Why two contexts: dispatch is stable forever, so components that only dispatch (a "New task" button) subscribe to a context that never changes value and never re-render from it. Only components reading tasks re-render on updates. When you outgrow this, updates so frequent that "every reader re-renders" is the bottleneck, or you need updates from outside React, that's the external-store territory covered in Part 4 (useSyncExternalStore), not a failure of this pattern.

Diagram

Rendered diagram (PNG hi-res):

rendered diagram

Rendered diagram (PNG hi-res):

rendered diagram

Common misconceptions

  • Misconception: Derived values need their own useState to "stay updated". Reality: values computed during render recompute every render, they're more up to date than any mirror, and cost zero extra renders.
  • Misconception: useState(props.x) keeps state synced to props. Reality: initial state is read once at mount; later prop changes are ignored. That's freezing, not syncing.
  • Misconception: Calling setState during render is always illegal. Reality: guarded prev-comparison updates during render are a documented, legal pattern, React re-renders before commit. Unguarded ones are the infinite loops you're thinking of.
  • Misconception: Deeply nested state is fine with enough spreading. Reality: normalized entities-by-id make updates shallow, single-sourced, and impossible to desynchronize.
  • Misconception: Conditional rendering with && "hides" a component. Reality: it unmounts it, state destroyed. Hiding with CSS or Offscreen preserves state.
  • Misconception: Context plus a reducer replaces all state libraries. Reality: it covers medium-complexity shared state; high-frequency updates and external sources are external-store territory.

Why it works this way

  • One source of truth is the whole game. Every bug in this chapter's anti-patterns, stale mirrors, frozen initials, desynced copies, comes from storing the same fact twice. Deriving during render keeps exactly one copy.
  • Render-phase updates exist because some adjustments are inherently synchronous. "Clear the selection if the item vanished" must happen before paint, not in an effect after the user saw a stale frame.
  • Keys-as-reset works because identity is explicit. Rather than guessing when you want fresh state, React lets you declare it: new key, new instance.
  • Colocation works because render cost follows the state. State at the top means the whole tree pays for every keystroke; state at the leaf means the leaf pays. The tree structure is a budget, and you choose where to spend.

Try it yourself

  1. Take the Cart mirror example and log total during render. Observe the 0 on first paint and the double render on every items change, then delete the effect and confirm identical UI with one render.
  2. Build Selection from Escape 2 with a button that removes the selected item from items. Verify the selection clears in the same commit, log renders to confirm there's exactly one commit per removal.
  3. Create a Toggle component defined inside its parent, put an input in it, type, then trigger a parent re-render. Watch the input lose focus and text. Move the definition out; problem gone. Explain it using the decision map.
  4. Refactor the nested post state to the normalized shape and implement "like a reply" both ways. Count the lines of copying each requires.

Recap

  • Colocate state in the smallest component that needs it; lift only to the lowest common ancestor of real consumers. Moving state beats memoizing around it.
  • Derived state: compute during render. The useState + useEffect mirror is two renders and a stale-bug generator.
  • Mirroring props freezes or double-renders. Blessed escapes: <Comp key={id} /> for deliberate resets, guarded prev-comparison setState during render for adjustments.
  • Normalize nested data: entities by id, arrays of ids. Updates become shallow; facts live once.
  • The preserve/reset map: same position + type + key preserves; anything else resets. Conditional && unmounts, it doesn't hide.
  • Reducer + two split contexts handles medium shared state; external stores take the heavy stuff.

Next

The React Compiler →