Skip to content

State ownership taxonomy

Every fact has one owner, as close to its readers as it can be; anything computable from owned state is computed, never stored.

Predict first

Before you read: predict

ExamHall's exam page has a review mode in which the candidate filters the question tiles. The exam has 40 questions, and the candidate has flagged 6 of them.

Two things must hold. The grid and the count line always match the lit button. And each time the candidate opens review mode, the filter starts at "all".

Where should filter live?

Code for this question
ExamPage├─ ExamTimer├─ QuestionEditor└─ ReviewPanel        rendered only while review mode is open   ├─ FilterBar       the "all" and "flagged" buttons: they set filter   ├─ QuestionGrid    shows the tiles that match filter   └─ CountLine       "showing 6 of 40"
Pick one

The 30-second version

Give each piece of state one owner. The server owns shared data; the client's copy can go stale. The address owns what a link or reload must restore. A form owns its draft. Everything else lives in the lowest component above all its readers: it re-renders only that subtree and resets with it. Compute derivable values during render.

Every fact has one owner, as close to its readers as it can be; anything computable from owned state is computed, never stored.

How it works

Step 1 of 11

One owner per fact. Each fact is stored in exactly one place. Every other component reads it from there, or receives it as props.

Analogy: A lettings agency's branch office

Every fact about a flat has one keeper, and everyone else asks that keeper.

At the branch In the app
The signed lease and the rent sit with the landlord's solicitor; the branch file is a copy that may be out of date server state (step 2)
The flat's address on a viewing slip, which anyone can carry to the door URL state (step 3)
The applicant's half-filled form on the clipboard, handed in only when complete form state (step 4)
A negotiator's own jotting of her next viewing local state (step 5)
The whiteboard of keys out, read by every desk a shared store (step 6)
"Keys out: 3" written beside that list, wrong the day someone forgets to change it a stored derived value (step 10)

In real terms: each value has one owner (the server, the URL, a form, a component or a store); everything else reads it, and derived values are recomputed from the owners on every render.

Breaks down when:

  • The whiteboard stays on the wall overnight. A store is gone after a reload (step 6).
  • A negotiator's note survives her lunch break. A component's state is discarded the moment its owner unmounts (step 9).

Contrasting case: pinning every negotiator's private notes on the shared whiteboard "so anyone can find them". That is the global store as a default: every desk reads round them, and a note outlives the viewing it was for.

Examples, run for real

4.1 ExamHall EXH-1460: the header's flagged count goes stale

The proctor console header said "2 flagged" while one tile was marked. flaggedCount was stored beside flaggedIds, and the flag and unflag handlers kept it in step; the live feed removes a candidate who finishes, from the tiles and from flaggedIds, and nobody remembered the count. MR !1660 computes it during render: const flaggedCount = flaggedIds.length.

4.1 stored   | flag c-41, c-42, c-43: header "3 flagged", tiles 3 | unflag c-42: header "2 flagged", tiles 2 | feed: c-43 finished: header "2 flagged", tiles 1
4.1 derived  | flag c-41, c-42, c-43: header "3 flagged", tiles 3 | unflag c-42: header "2 flagged", tiles 2 | feed: c-43 finished: header "1 flagged", tiles 1

Both versions agree until a third path changes the list (step 10). An effect that copies the count is a different bug, with an extra render: react-effects-synchronization.

4.2 Catalog: a search draft owned too high

CatalogPage renders a SearchBox and 48 ProductCards. The typed draft is read only by the search box; the submitted query is read by the grid.

4.2 draft page-level | typing "boots" (5 keys): ProductCard renders 240 | Search: 48 | query boots
4.2 draft colocated  | typing "boots" (5 keys): ProductCard renders   0 | Search: 48 | query boots

With the draft in CatalogPage, every keystroke re-renders all 48 cards (step 8). Moved into SearchBox, typing renders no card; only the submitted query, which the grid needs, stays in the page (step 7).

4.3 ExamHall: whose is the draft answer?

A textarea can show the server's saved answer and save on every change, or keep its own draft until Save. The candidate types "abc"; the server's replies then arrive in order.

4.3 field shows the saved answer | typed "abc" | shows "c" | saves while typing 3 | sent ["a","b","c"]
4.3 form keeps the draft       | typed "abc" | shows "abc" | saves while typing 0 | sent ["abc"]

A server-owned field snaps back to the saved value after each keystroke, so letters are lost. The draft belongs to the form, and the server gets it on submit (steps 2 and 4). Controlled and uncontrolled fields: react-actions-forms step 1.

Picture it

Concept illustration for this lesson.
An illustration to help it stick. The mechanism is in the sections above.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 7

A catalog's category page has size and colour chips. A shopper taps size M, then blue. Two things must work: she can share the page with a friend, whose browser opens it with the same chips on, and her browser's Back button takes the last chip off.

Where should the chosen chips be kept?

Pick one

Question 2 of 7

A help desk lists its tickets at /tickets?page=1. TicketList reads the page number from the address once, into const [page, setPage] = useState(…). Next calls setPage(page + 1) and pushes ?page= with the new number. The site footer, outside TicketList, has a "Back to page 1" link that pushes ?page=1. After Next, Next and the browser's Back, the address says page=2 but the list still shows page 3.

Which change keeps the list matching the address after Back and after the footer link?

Pick one

Question 3 of 7

A catalog's compare drawer shows two products side by side. As written, CompareDrawer holds qtys = { 'p-1': 1, 'p-2': 1 } and passes all of it to both panels:

One tap on p-1's "+" re-renders all 40 ReviewRows. The goal: a tap on "+" renders no ReviewRow, and each Add button still sends its own panel's quantity.

Which change meets it?

Code for this question
CompareDrawer└─ ProductPanel (one per product)   ├─ BuyBox   │  ├─ Stepper       "−  1  +"   │  └─ AddButton     "Add to cart": sends this panel's quantity   └─ ReviewList       20 ReviewRows
Pick one

Question 4 of 7

A category page lists 16 products under a price slider:

The shopper slides to €40, and then the feed raises one product from €38 to €42. The heading now says 8 results above 7 cards.

Which change makes the heading and the cards match the live prices after both steps?

Code for this question
const [products, setProducts] = useState(PRODUCTS);  // the live feed calls setProducts(next) when a price changesconst [maxPrice, setMaxPrice] = useState(50);const [count, setCount] = useState(12);              // 12 of the 16 cost €50 or lessfunction onSlide(v) { setMaxPrice(v); setCount(products.filter((p) => p.price <= v).length); }const visible = products.filter((p) => p.price <= maxPrice);return <><h2>{count} results</h2><Grid items={visible} /></>;
Pick one

Question 5 of 7

A help desk's ticket page shows the thread and a Reply button. Reply opens the reply form: a textarea with Send and Cancel, and Cancel closes it. Two things must hold: after Cancel, the next Reply opens an empty box; and the browser's Back, pressed while typing, returns to the inbox.

Where should the typed reply be kept?

Pick one

Question 6 of 7

The proctor console lives at /console. Tapping candidate c-41's tile pushes /console?candidate=c-41, and the page reads the selected candidate from the address. The review filter is useState in ReviewFilter. The alert-sound setting is a field in a console store module that the header reads. The note box is a textarea whose draft is useState in NoteForm. The proctor selects c-41, sets the filter to flagged, mutes the alert sound and types "check webcam" in the note. Then she presses Reload.

What does the console show?

Pick one

Question 7 of 7

A music app's Layout renders a PlayerBar on every page, beside a router that shows one page at a time (the playlist, search, or a song's page). The team's state plan:

Requirements: Back from a song's page returns to the same search results; a track the listener adds from her phone shows up when she opens the playlist again; the volume stays as set while she moves between pages; the song keeps playing, shown in the player bar, on every page.

Which line fails its requirement?

Code for this question
1  search text      ?q= in the address, read as the Search page renders2  playlist tracks  read from the server into an app store when the app starts; the app's own add button updates the store3  volume           useState in PlayerBar4  song playing     useState in Layout, passed to PlayerBar and to the playlist rows
Pick one

Go deeper

Pro tips
  1. In a pull request that adds state, write its owner next to it: server, URL, form, local or store, and which component.
    • When it helps: reviewing any MR that adds a useState or a store field (steps 1 and 7).
    • Verify: react.dev, "Sharing State Between Components": "For each unique piece of state, you will choose the component that 'owns' it.".
    • Status: DOC.
  2. Before and after moving state, count renders of the heavy components with a counter in their body or the React DevTools Profiler.
    • When it helps: a keystroke or a tap feels slow on a big page (step 8).
    • Status: RAN (React 19.3.0, Node 24.19.0, jsdom). The Profiler itself was not run.
Source
  • react.dev, "Choosing the State Structure": https://react.dev/learn/choosing-the-state-structure · fetched 2026-09-30. Quoted: "If you can calculate some information from the component's props or its existing state variables during rendering, you should not put that information into that component's state"; "instead of a selectedItem object (which creates a duplication with objects inside items), you hold the selectedId in state"; "you have duplicated state, and you forgot to update selectedItem". Supports steps 1, 10 and 11. Valid as of 2026-09-30.
  • react.dev, "Sharing State Between Components": https://react.dev/learn/sharing-state-between-components · fetched 2026-09-30. Quoted: "move it to their closest common parent, and then pass it down to them via props"; "For each unique piece of state, you will choose the component that 'owns' it … It doesn't mean that all state lives in one place"; "Some state may 'live' close to the leaf components". Supports steps 1 and 7 and tip 1. Valid as of 2026-09-30.
  • react.dev, "Preserving and Resetting State": https://react.dev/learn/preserving-and-resetting-state · fetched 2026-09-30. Quoted: "React preserves a component's state for as long as it's being rendered at its position in the UI tree. If it gets removed, or a different component gets rendered at the same position, React discards its state." Supports step 9. Valid as of 2026-09-30.
  • TanStack Query, "Overview": https://tanstack.com/query/latest/docs/framework/react/overview · fetched 2026-09-30. Server state "Is persisted remotely in a location you may not control or own", "Implies shared ownership and can be changed by other people without your knowledge" and "Can potentially become 'out of date' in your applications if you're not careful". Supports step 2. Valid as of 2026-09-30.
  • TanStack Query, "Does TanStack Query replace Redux, MobX or other global state managers?" (the curriculum row's source): https://tanstack.com/query/latest/docs/framework/react/guides/does-this-replace-client-state · fetched 2026-09-30. Quoted: "the truly globally accessible client state that is left over after migrating all of your async code to TanStack Query is usually very tiny", with themeMode and sidebarStatus as its example. Supports step 6. Valid as of 2026-09-30.
  • MDN, "History: pushState() method": https://developer.mozilla.org/en-US/docs/Web/API/History/pushState · fetched 2026-09-30. Quoted: "adds an entry to the browser's session history stack", and a popstate event "is fired" when the user navigates to it. Supports step 3 and the router in the runs. Valid as of 2026-09-30.

Checked against the tools and docs as of 2026-09-30.