Command Palette

Search for a command to run...

Hectal
PHASE 6Intermediate ~24 min· topic 1 of 6

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 isSending and isSent both being true.
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.

Feedback.jsx (first draft)whole filejsx
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.

Feedback.jsx (better)whole filejsx
const [text, setText] = useState("");
const [status, setStatus] = useState("typing"); // "typing" | "sending" | "sent"

const isSending = status === "sending"; // calculated, not stored
const isSent = status === "sent";
Replace the booleans with one statusdiagram
Rendering diagram…

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.

Menu.jsxwhole filejsx
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.

Flatten nested datadiagram
Rendering diagram…

Try it yourself

  1. 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 logs status: typing again on each key, then status: sending, then status: sent.)

  2. 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 selectedCopy completely: the component gets shorter and the bug disappears.

  3. 3

    Clean up deleted entries

    In the travel plan, make handleDelete also remove the deleted id (and, for a bonus, its children) from the table. Hint: copy places, then delete copy[id]. Mutating a fresh copy is fine; mutating the state object is not.

Code & diagrams

One status instead of two booleans New tab

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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

status: typing
Store the id, not a copy New tab

The 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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

From the copy: Chai
From the id: Masala Chai
Flat state: delete a place by touching two entries New tab

Click 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

Press Run to see the page.

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.

terminal
$ Preview
── what you'll see ──
Thanks for the feedback!
[ greyed-out text box ]
95 characters left (after sending "Great")
[ Sending… ] (disabled, forever)

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. 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. 2

    Group related state. If two values always change together, such as a pointer's x and y, keep them in one object: const [position, setPosition] = useState({ x: 0, y: 0 }). Then one setPosition({ 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 separate useState calls. 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. 3

    Avoid contradictions. Two booleans like isSending and isSent allow four combinations, and one of them (true and true) is nonsense. Sooner or later a missed set creates 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. 4

    Avoid redundant state. Redundant state is a value you could calculate from props or other state during render, such as fullName from firstName and lastName, 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. 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. 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

01

Name the five principles for structuring state and give a one-line example of each.

02

Why is one status variable better than several booleans? How do you keep readable names like isSending?

03

Why should you store a selected item's id instead of the item itself?

04

What is normalised state, and when is it worth it?

Practice

01

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.

02

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.