I7 · Part 2 · Intermediate
State architecture
Most React problems are state in the wrong place, in the wrong shape, or duplicated.
07On this page
Five kinds of state
"State management" is not one problem. Naming the kind you have usually names the tool.
| Kind | Examples | Where it belongs |
|---|---|---|
| Local UI | Is this menu open, which tab, hover | useState, in the component |
| Form | Field values, touched, errors | useState, or a form library at scale |
| URL | Filters, sort, page, search, the open item | The URL — useSearchParams |
| Server cache | Anything fetched from an API | A data library, not useState |
| Shared client | Theme, auth, cart, a toast queue | Context, or a store |
The single biggest improvement most React codebases can make is moving rows
three and four out of useState. Filters belong in the URL; server
data belongs in a cache that knows about staleness.
Server state is not client state
Data from an API is a cached copy of something you do not own. It can
be stale, it needs refetching, several components want the same copy, and it
has loading and error states. Putting it in useState means
reimplementing all of that by hand, badly, in every component that fetches.
The three questions before adding state
- Can it be derived? If it can be computed from props or other state, compute it. Do not store it.
- Does the UI change when it changes? If not, it is a ref.
- Who needs it? Put it at the closest common ancestor of everyone who reads it — and no higher.
const [items, setItems] = useState([]);
const [count, setCount] = useState(0); // ✗ derived — will drift
const [selectedItem, setSelectedItem] = useState(null); // ✗ a whole copy
const count = items.length; // ✓
const [selectedId, setSelectedId] = useState(null);
const selectedItem = items.find((i) => i.id === selectedId); // ✓ always fresh
Storing the selected object rather than its id is the classic duplication bug: edit the item in the list and the selection still shows the old version, because they are two separate copies now.
Shape it so bad states cannot exist
// ✗ 16 combinations, 4 of them valid
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [data, setData] = useState(null);
// ✓ 4 states, and they are the 4 that exist
const [state, setState] = useState({ status: "idle" });
// { status: "loading" } | { status: "error", error } | { status: "ready", data }
With separate flags, "loading and also has an error" is one forgotten setter away, and the UI that results is genuinely confusing. With a status field it is unreachable. useReducer is the natural home for this once there are more than two transitions.
Normalise when things reference each other
// ✗ the same author object copied into every post
posts: [{ id: 1, author: { id: 9, name: "Ana" } }, ...]
// ✓ one copy, referenced by id
posts: { 1: { id: 1, authorId: 9 } },
authors: { 9: { id: 9, name: "Ana" } }
Renaming Ana now touches one place instead of every post she wrote. Lookups become O(1). This is a database idea and it applies for the same reason: duplicated data drifts.
Do not normalise a list of twenty things you render once. Normalise when the same entity appears in several places, or when you update items by id.
Colocation: the default that keeps apps fast
State at the top of the tree re-renders everything below it. State next to where it is used re-renders one subtree. The instinct to "lift it up in case something else needs it" is what makes an app slow and its components untestable.
function App() {
const [isMenuOpen, setMenuOpen] = useState(false); // ✗ whole app re-renders
}
function Menu() {
const [isOpen, setOpen] = useState(false); // ✓ only the menu
}
Lift when two components genuinely need the same value; push it back down the moment only one does.
A decision tree
| Question | Answer |
|---|---|
| Comes from a server? | A data library. TanStack Query. |
| Should survive a refresh or a shared link? | The URL. |
| Used by one component? | useState, right there. |
| Used by a few nearby components? | Lift to the common parent. |
| Used everywhere, changes rarely? | Context. |
| Used everywhere, changes often? | A store with selectors. |
| Does not affect rendering? | A ref, or a module variable. |
The refactor that fixes most apps
Take a component with eight useState calls. Usually two of them
are derived and can be deleted, two belong in the URL, three are server data
that should be one query, and one is genuinely local. Eight becomes two, and
the effects that were keeping them in sync disappear with them.
Interview answer, one sentence
"I split state by kind before choosing a tool — server cache, URL, form,
local UI, shared client — because most 'state management' problems are
server data sitting in useState, or filters that should be in
the URL. Then: derive what you can, keep the rest as low as possible, and
shape it so impossible combinations cannot be represented."
Opens in the editor: write it, run it, and check it against real tests.
All 66 for this level →Finished reading?
0 of 57 chapters read in this series