Component decomposition and client data model
Shape the data so an update replaces one record, and shape the components so only that record's readers run.
Predict first
Before you read: predict
A proctor's console shows 4 rooms of 10 candidates. useStore(select) runs its component again only when select(state) returns a value that differs by Object.is from last time, like React Redux's useSelector. Every component below ProctorConsole, RoomHeader included, is wrapped in memo. Alerts are applied with immutable updates, as React requires. The team has written the store two ways:
One alert arrives for candidate c-17, who sits in room r2.
How many component functions run in each version?
// nested: kept as the API returns it// state = { rooms: [{ id, name, candidates: [{ id, name, alerts }] }] }function ProctorConsole() { const rooms = useStore((s) => s.rooms); return rooms.map((r) => <RoomSection key={r.id} room={r} />);}const RoomSection = memo(({ room }) => <section> <RoomHeader name={room.name} /> {room.candidates.map((c) => <CandidateTile key={c.id} candidate={c} />)}</section>);const CandidateTile = memo(({ candidate }) => <p>{candidate.name}: {candidate.alerts}</p>); // normalized// state = { roomIds: [...], rooms: { r1: { id, name, candidateIds } }, candidates: { 'c-01': { id, name, alerts } } }function ProctorConsole() { const roomIds = useStore((s) => s.roomIds); return roomIds.map((id) => <RoomSection key={id} roomId={id} />);}const RoomSection = memo(({ roomId }) => { const room = useStore((s) => s.rooms[roomId]); return <section><RoomHeader name={room.name} /> {room.candidateIds.map((id) => <CandidateTile key={id} candidateId={id} />)}</section>;});const CandidateTile = memo(({ candidateId }) => { const c = useStore((s) => s.candidates[candidateId]); return <p>{c.name}: {c.alerts}</p>;});The 30-second version
Cut components where the data's owner or change rate changes. Keep one table per entity type, keyed by id, plus a view-state slice pointing at records by id. Parents pass ids; each component reads its own record. An update replaces a record and its table, so only its readers run; nested state also replaces every ancestor.
Shape the data so an update replaces one record, and shape the components so only that record's readers run.
Step through the code
Trace of the opening question
Play options
Ready: press Play or step with Next frame
Row 1 of 4 · What the alert makes new c-17's record
Starting row · computed by comparing each row with the one before
- What the alert makes new: c-17's record
- Nested: new, so its tile runs
- Normalized: new, so its tile runs
Rule · step 7
An update replaces its path. An immutable update makes a new record and new copies of each container above it; the rest keeps its identity.
- What the alert makes new
- c-17's record
- Nested
- new, so its tile runs
- Normalized
- new, so its tile runs
- Step
- 7
| What the alert makes new | Nested | Normalized | Step |
|---|---|---|---|
| c-17's record | new, so its tile runs | new, so its tile runs | 7 |
| its room, and the room list (changed) | new, so r2's section and the console run (changed) | untouched: the room holds ids (changed) | 7, 9 |
| the other 3 rooms and 39 candidates (changed) | kept (changed) | kept (changed) | 7 |
| component functions that run (changed) | 3 (changed) | 1 (changed) | 8, 9 |
Space plays or pauses · ←/→ one row · Home/End first/last
Rows come from the pack's own trace table.
The probe code (rows are not linked to code lines)
// nested: kept as the API returns it// state = { rooms: [{ id, name, candidates: [{ id, name, alerts }] }] }function ProctorConsole() { const rooms = useStore((s) => s.rooms); return rooms.map((r) => <RoomSection key={r.id} room={r} />);}const RoomSection = memo(({ room }) => <section> <RoomHeader name={room.name} /> {room.candidates.map((c) => <CandidateTile key={c.id} candidate={c} />)}</section>);const CandidateTile = memo(({ candidate }) => <p>{candidate.name}: {candidate.alerts}</p>); // normalized// state = { roomIds: [...], rooms: { r1: { id, name, candidateIds } }, candidates: { 'c-01': { id, name, alerts } } }function ProctorConsole() { const roomIds = useStore((s) => s.roomIds); return roomIds.map((id) => <RoomSection key={id} roomId={id} />);}const RoomSection = memo(({ roomId }) => { const room = useStore((s) => s.rooms[roomId]); return <section><RoomHeader name={room.name} /> {room.candidateIds.map((id) => <CandidateTile key={id} candidateId={id} />)}</section>;});const CandidateTile = memo(({ candidateId }) => { const c = useStore((s) => s.candidates[candidateId]); return <p>{c.name}: {c.alerts}</p>;});How it works
Step 1 of 11
See it run
Not in this run
Analogy: A weather service's station register and regional maps
| At the weather service | In the client data model |
|---|---|
| The register keeps one entry per station, under its code (step 3) | one table per entity type, keyed by id |
| A regional map lists station codes; each symbol is drawn from its own entry (step 6) | a parent holds ids; each child reads its own record |
| A new reading at one station redraws that one symbol (step 9) | one record is new, and only its readers run |
| The old bulletin copied every station's figures into each region's page, so one reading meant a new page and a new bulletin for everyone holding one (step 7) | nested state: a new record makes every ancestor new |
| Altitude and location sit in the register; the ten-minute readings go on their own feed sheet (step 2) | fast-changing data in its own record |
| The forecaster draws her marks on a clear overlay, never on the register, which head office reprints (step 5) | view state in its own slice, pointing at records by id |
In real terms: a normalized store keeps each record once, under its id, with view state beside it. An update replaces one record and its table, so the only components that run are those that read that record.
Breaks down when: a clerk corrects a register entry in place, and the old entry is gone. A React update leaves the old object untouched and makes a new one; what matters is which objects are new.
Contrasting case: one hand-painted wall map carrying every figure. A new reading means repainting the wall: one component that shows everything.
Examples, run for real
4.1 ExamHall: the exam page stutters while a candidate types
EXH-1450: candidates saw the ECON-201 exam page stutter while typing. The page kept the exam JSON nested, with each answer inside its question, and the navigator took the whole sections array. MR !1650 normalizes the exam where it enters the app: questions by id, answers by question id, sections holding question ids. Each answer editor and navigator dot reads its own entry. The page has 4 sections of 10 questions, and Q17 is saved twice:
4.1 nested | first autosave of Q17: ExamPage 1, QuestionNav 1, NavDot 1, SectionPane 1, QuestionPane 1
4.1 | next autosave of Q17: ExamPage 1, QuestionNav 1, SectionPane 1, QuestionPane 1
4.1 normalized | first autosave of Q17: NavDot 1, AnswerEditor 1
4.1 | next autosave of Q17: AnswerEditor 1
Nested, each save makes a new question, section and sections array, so the page, the navigator and the section run (steps 7–9). Normalized, only Q17's answer is new. The dot reads "answered or not", so it runs once, when that flips; the editor runs on every save (steps 6, 9).
4.2 Proof domain, catalog: normalizing where the response enters
A listing response holds two lists, bestsellers (12) and lamps (48), and every product embeds its brand:
4.2 response: 2 lists, 60 product entries, 60 embedded brand copies
4.2 normalized: products 52, brands 5, lists hold 12 and 48 ids
4.2 a price change for p-07: nested copies to update 2, normalized records to update 1
Eight bestsellers are also lamps, so the response carries them twice, and 60 brand objects stand for 5 brands. One boundary function turns it into tables (steps 3–4):
{ products: { 'p-07': { id, name, price, brandId: 'b3' } }, brands: { b3: { id, name } },
lists: { bestsellers: ['p-03', 'p-07', …], lamps: ['p-01', …] },
view: { expandedIds: {} } } // view state apart (step 5); picked facets go in the URL (fsd-state-ownership)
Picture it

Try it
Answer first, then check. Nothing is saved.
Question 1 of 5
A category page renders 48 ProductCards from a normalized store. products[id] holds name, images, description, price and stock, and a price feed updates one product's price several times a second. Each card reads its product with useStore((s) => s.products[id]) and renders a memo(Gallery), a memo(Description) and a price line. The Profiler shows each tick running that product's ProductCard.
Which single change makes a tick run one small price component and not the card?
Question 2 of 5
A video app's store keeps a series' 12 episodes in episodes (by id), and the list renders a memo(EpisodeRow) per id. Episode 4 is playing, so its record is also read by the Continue-watching strip and the mini-player; every component selects s.episodes[id]. When an episode's watch progress changes, the server pushes its full record, and the store puts it where the old one was. A row can be expanded from the row itself or from the mini-player, so "expanded" is kept in the store. The viewer expands Episode 4's row, and then its progress changes.
Which way of keeping "expanded" runs just that row on the tap, and still has the row open after the progress push?
Question 3 of 5
A chat store is normalized: threads[id] holds { id, title, messageIds } and messages[id] holds { id, text, reactions }. ThreadView reads its thread with useStore((s) => s.threads[threadId]) and renders a memo(MessageRow) per id, and each row selects its own message. Updates are immutable. In a thread of 20 messages, three things happen: m05 is edited, someone reacts to m09, and a new message m21 is posted.
How many times does ThreadView run?
Question 4 of 5
A ticket shop's store keeps its seat map as the API returns it: 3 sections of 5 rows of 20 seats. useStore(select) runs its component again only when select(state) returns a value that differs by Object.is. Each hold by another buyer is an immutable update to one seat.
One hold arrives, and the Profiler shows SeatMap, one Section, one SeatRow and one Seat running.
Which single change makes a hold run just the held seat's component, still showing "held"?
// state = { sections: [{ id, rows: [{ id, seats: [{ id, status }] }] }] }function SeatMap() { const sections = useStore((s) => s.sections); return sections.map((sec) => <Section key={sec.id} section={sec} />);}const Section = memo(({ section }) => section.rows.map((r) => <SeatRow key={r.id} row={r} />));const SeatRow = memo(({ row }) => row.seats.map((seat) => <Seat key={seat.id} seat={seat} />));const Seat = memo(({ seat }) => <i>{seat.status}</i>);Question 5 of 5
A banking dashboard's store is normalized: accountIds and accounts[id]. AccountList reads accountIds and renders a memo(AccountRow) per id, and each row selects s.accounts[accountId]. On each refresh the app fetches all 120 accounts and stores them with setAll(list), which builds accounts and accountIds from the response. In a refresh where 3 balances changed, the Profiler shows all 120 AccountRows run.
What is the cause?
Go deeper
Pro tips
- Pair React's
<Profiler>with a per-component counter when you test a data model.- When it helps: checking a design claim such as "an alert runs only that tile" before the design round moves on (steps 8–9).
- Verify: one
<Profiler id="console">around the opening question's console, one alert:tip nested | Profiler onRender: console:update | counter: ProctorConsole 1, RoomSection 1, CandidateTile 1;tip normalized | Profiler onRender: console:update | counter: CandidateTile 1. The Profiler reports one update in both stores; only the counter shows 3 against 1. - Status: RAN (React 19.3.0, jsdom).
onRenderreports that a subtree committed, not how many components ran in it, so the counter is still needed.
- In the data-model minutes of a frontend design round, write the state shape before the components: which entities there are, the fields each holds, and which component reads which part.
- When it helps: the RADIO framework's "D" step (step 3 onwards).
- Verify: the Front End Interview Handbook's RADIO outline defines the data model step as "Describe the various data entities, the fields they contain and which component(s) they belong to".
- Status: DOC.
Source
- react.dev, "Choosing the State Structure": https://react.dev/learn/choosing-the-state-structure · fetched 2026-09-30. Quoted: "Avoid deeply nested state. Deeply hierarchical state is not very convenient to update. When possible, prefer to structure state in a flat way." and "Now that the state is “flat” (also known as “normalized”), updating nested items becomes easier." Its snack menu keeps "the selectedId in state" instead of "a selectedItem object (which creates a duplication with objects inside items)". Valid as of 2026-09-30.
- react.dev, "Thinking in React": https://react.dev/learn/thinking-in-react · fetched 2026-09-30. Quoted: "a component should ideally only be concerned with one thing. If it ends up growing, it should be decomposed into smaller subcomponents." and "Separate your UI into components, where each component matches one piece of your data model." Supports steps 1–2. Valid as of 2026-09-30.
- Redux docs, "Normalizing State Shape": https://redux.js.org/usage/structuring-reducers/normalizing-state-shape · fetched 2026-09-30. Quoted: "Since immutable data updates require all ancestors in the state tree to be copied and updated as well, and new object references will cause components that read them with useSelector to re-render, an update to a deeply nested data object could force totally unrelated UI components to re-render" and "having parent components simply pass item IDs to children that select their own item is a good pattern for optimizing UI performance". It also shows an
entitiessection beside auisection. Supports steps 4–9. Valid as of 2026-09-30. - Front End Interview Handbook, "Front End System Design": https://www.frontendinterviewhandbook.com/front-end-system-design · fetched 2026-09-30. Quoted (RADIO, "D"): "Describe the various data entities, the fields they contain and which component(s) they belong to". Supports pro tip 2. Valid as of 2026-09-30.
Related lessons
Checked against the tools and docs as of 2026-09-30.