Topic 6.1
Choosing the State Structure
In one line
Good state has no room for bugs: group values that change together, replace clashing booleans with one status, don't store what you can calculate, don't keep copies of data you already have (store its id), and keep nested data flat.
Think of it like this
A school register. The teacher writes each child's name once, in one list. The class total isn't written in a separate box that someone must remember to update; it's counted from the list. A child's seat is recorded as a seat number, not by photocopying the child's whole page. Good React state works the same way: each fact stored once, everything else worked out from it.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- State structure
- Which state variables a component has and what shape each one is.
- Contradiction
- Two state values that disagree, like
isSendingandisSentboth beingtrue. - Status (enum)
- One state variable that holds one of a fixed set of values, like
"typing","sending"or"sent". - Redundant state
- State you could calculate from other state or props, so storing it is unnecessary.
- Duplication
- The same data kept in two places, such as an item in a list and a copy of it as "selected".
- Id
- A unique value (number or string) that names one item, so you can find it without copying it.
- Normalised (flat) state
- Data stored like a table: items keyed by id, linked by lists of ids instead of nesting.
- Single source of truth
- Each fact is stored in exactly one place; everything else reads or calculates from it.
Step by step
01Start from what the screen needs
Before writing useState, list every piece of data the screen shows or depends on. For a feedback form: the text the user typed, and where the form is in its life (typing, sending, sent). That's the minimal list. Everything else, such as "is the button disabled" or "how many characters are left", can be calculated.
02Spot the impossible combinations
A first draft often looks like this. Count the combinations: two booleans give four, and one of them is impossible. Each impossible combination is a bug waiting for a missed set call.
const [text, setText] = useState("");
const [isSending, setIsSending] = useState(false);
const [isSent, setIsSent] = useState(false);
// isSending && isSent === true: spinner and "Thanks!" at once?03Replace the booleans with one status
One status variable can only hold one value at a time, so the impossible state is gone. Readable names come back as calculated constants.
const [text, setText] = useState("");
const [status, setStatus] = useState("typing"); // "typing" | "sending" | "sent"
const isSending = status === "sending"; // calculated, not stored
const isSent = status === "sent";04Remove what you can calculate
If you were also keeping charsLeft in state, delete it. const charsLeft = 200 - text.length runs on every render and is always right. The rule of thumb: if you can calculate it from props or state, it isn't state.
05Keep ids, not copies
A list with a "selected" item is the classic trap. Keep the list as the only home of each item, and remember which one is selected by its id.
const [items, setItems] = useState(initialItems);
const [selectedId, setSelectedId] = useState(0);
// found during render, so it always matches the list
const selected = items.find((item) => item.id === selectedId);06Flatten nested data
Nested data mirrors how it looks on screen, but updating it means copying every level. A flat table keyed by id makes each update small: to delete a place, remove its id from its parent's childIds.
Try it yourself
- 1
Predict the status logs
In the feedback form, type one letter and press Send. Before looking, write down the
status:lines you expect after the first one. (Typing logsstatus: typingagain on each key, thenstatus: sending, thenstatus: sent.) - 2
Find the stale copy yourself
In "Store the id", click Choose next to Samosa, then type in the Samosa box. Which line follows your typing? Now delete
selectedCopycompletely: the component gets shorter and the bug disappears. - 3
Clean up deleted entries
In the travel plan, make
handleDeletealso remove the deleted id (and, for a bonus, its children) from the table. Hint: copyplaces, thendelete copy[id]. Mutating a fresh copy is fine; mutating the state object is not.
Code & diagrams
Type a message and send it. The status moves typing → sending → sent and can never be two things at once. `isSending` and `charsLeft` are calculated, so they can't drift.
Preview
Console
Nothing logged yet.Expected console output
status: typingThe copy was taken before the rename, so it still says Chai. The id points into the list, so it always shows the current title. Edit a title yourself and watch both lines.
Preview
Console
Nothing logged yet.Expected console output
From the copy: Chai
From the id: Masala ChaiClick Done on Japan. Only the Asia entry is copied; nothing deeper or higher is touched. (The removed entry stays in the table; Try it below cleans it up too.)
Preview
Console
Nothing logged yet.Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Two booleans that disagree
In the feedback form, replace status with two booleans, isSending and isSent. In handleSubmit call setIsSending(true), await, then setIsSent(true), and forget setIsSending(false). Instead of the early return, render {isSent && <h2>Thanks for the feedback!</h2>} above the form. Send a message.
Myth vs fact
Myth
More state variables make a component more flexible.
Fact
Every extra variable is another thing that can go out of step. The best structure is the smallest one that can describe every screen your component shows.
Myth
Grouping all state into one big object is cleaner.
Fact
Group only values that change together. One giant object forces every update to copy unrelated fields, and it is easy to drop one by accident.
Myth
Keeping the selected object in state saves a find on every render.
Fact
A find over a normal list is tiny compared with rendering. The copy costs you correctness; measure before trading it away (Phase 16).
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
This is the same thinking as database normalisation (each fact stored once, linked by keys) and as "make illegal states unrepresentable" from typed functional programming. In TypeScript a status becomes a discriminated union (
{ status: "error"; error: string } | { status: "done"; data: Item[] }), so the error message only exists in the error state (Topic 12.3). - ▸
A status variable is a small state machine. When the transitions themselves get complicated (which status can follow which), move them into a reducer (Topic 9.4) or a state-machine library such as XState (Topic 15.5).
- ▸
Flat state helps performance too: with items keyed by id, a memoised row (Topic 16.3) receives the same object unless that row changed, so unchanged rows can skip rendering. Deep copies of nested trees create new objects all the way up and defeat that.
- ▸
Normalising has a cost: rendering needs lookups (
places[id]), and deleting must also clean up orphans. For a small, shallow, read-mostly tree, nesting is fine. Normalise when updates get painful.
Remember this
- 1
State structure means which state variables a component has and what shape each one is (a number, an object, an array of objects). Two components can show exactly the same screen with different structures, and one of them can be much easier to keep correct. The goal is simple to say: make wrong states impossible to represent. If the variables can't hold a contradiction, your code can't create one.
- 2
Group related state. If two values always change together, such as a pointer's
xandy, keep them in one object:const [position, setPosition] = useState({ x: 0, y: 0 }). Then onesetPosition({ x, y })updates both and you can't forget one. The opposite also holds: values that change independently (a search text and a sort order) are clearer as separateuseStatecalls. When you group into an object, remember the setter replaces the whole object, so copy the fields you keep:setPosition({ ...position, x: 5 })(Topic 4.6). - 3
Avoid contradictions. Two booleans like
isSendingandisSentallow four combinations, and one of them (trueandtrue) is nonsense. Sooner or later a missedsetcreates it and the screen shows a spinner and a thank-you message together. Replace them with one status that can only be one thing at a time:const [status, setStatus] = useState("typing")with the values"typing","sending"and"sent". You can still have readable names by calculating them:const isSending = status === "sending". - 4
Avoid redundant state. Redundant state is a value you could calculate from props or other state during render, such as
fullNamefromfirstNameandlastName, or the number of finished to-dos from the to-do list. Storing it means two places hold the same fact, and every update must change both. Calculate it in the component body instead; Topic 6.2 is all about this. - 5
Avoid duplication, store ids not copies. If you keep the selected item as a copy of an object that also lives in a list, editing the item in the list leaves the copy unchanged, and the screen shows the old name. Store the id instead (
selectedId), and find the item during render:const selected = items.find((i) => i.id === selectedId). There is then only one copy of the item, the one in the list. - 6
Avoid deep nesting. Deeply nested state (a folder inside a folder inside a folder) is painful to update without mutation: changing one leaf means copying every level above it. Store it flat instead, like a database table: an object keyed by id, where each item lists its children's ids (
childIds). This is called normalising the data. Updating or deleting one item then touches one or two entries, not the whole tree. Libraries such as Redux Toolkit ship helpers for exactly this shape (Phase 15).
Explain it without notes
Name the five principles for structuring state and give a one-line example of each.
Why is one status variable better than several booleans? How do you keep readable names like isSending?
Why should you store a selected item's id instead of the item itself?
What is normalised state, and when is it worth it?
Practice
A component has const [firstName, setFirstName] = useState(""), const [lastName, setLastName] = useState("") and const [fullName, setFullName] = useState(""), and every change handler also updates fullName. Simplify it.
A traffic light component uses isRed, isYellow and isGreen booleans. Rewrite its state with one variable and a button that moves to the next light (green → yellow → red → green).
Trade-offs
- ↔
Flat, normalised state is easier to update but harder to read and render (more lookups). Choose it when nesting makes updates painful, not by default.
- ↔
Grouping state into objects reduces the number of setters but makes each update copy more fields; separate variables are simpler when values change independently.
Done when you can
I can list the minimal state a screen needs before writing
useState.I can replace clashing booleans with one status variable.
I can spot and remove redundant state by calculating it.
I store ids of selected items instead of copies.
I can flatten nested data into a table keyed by id and update it without mutation.