01 / The prompt
“Build me a project board with drag and drop.”
Three columns: To do, Doing, Done. Doing holds at most three cards, because the team agreed to finish things before starting new ones. You drag a card, it moves, the count in the header ticks up. Each card also has a small Move to menu for people who use a keyboard. It all works in the demo.
Nothing in that demo tells you where the list of cards lives. It could live in the board, with every column reading from it. It could live in each column, copied from what the board handed down when the page loaded. It could live in a store that any component may import and write. All three versions look identical until someone drags a card and the page has to agree with itself.
React’s own documentation names the trap in the second version. Under “Don’t mirror props in
state” it says that if a parent passes a different value later, “the color state variable would not be updated! The state is only initialized during the first render.” (Choosing the State Structure, fetched 23 September 2026). Svelte’s compiler warns about the same thing: state_referenced_locally, “This reference only captures the initial value.”
And the third version fails a different way: a store fixes the four levels of props by giving every component the power to write, so the rule about Doing lives only where someone remembered to put it.
02 / Name the shape
One owner per piece of state.
State ownership means that every piece of state a UI keeps has exactly one component that holds it and decides how it changes. React’s docs call it a single source of truth: “for each piece of state, there is a specific component that holds that piece of information” (Sharing State Between Components). The owner is the nearest component above everything that reads the state. Everything below gets the current value as a prop and a function to ask for a change.
Put state in the nearest component above everything that reads it. Everyone else asks that owner to change it, through one function.
Here is who should own each piece of the board.
| State | Owner | Why |
|---|---|---|
| Which cards exist, and where each one is | Board | The columns, the header, and every card menu read it. Board is the nearest component above all of them. |
| The Doing limit | Board’s move | A rule about a change belongs with the one function that makes the change. |
| Each column’s count, and the header | Nobody: derived | Worked out from the list on every render. A stored count is a second copy that can disagree. |
| An unsaved title edit | That card | Nobody else reads it. Keyed by the card’s id, it follows the card when the list changes. |
| Whether a card’s Move to menu is open | CardMenu | Local and short-lived. Lifting it would make the board re-render for nothing. |
| The assignee filter | The URL | A reload or a shared link should keep it. The URL is state the browser already owns. |
Words to put in a prompt or a review
- Owner
- The one component that holds a piece of state and decides how it changes.
- Nearest common owner
- The closest component above everything that reads the state.
- Lifting state up
- Moving state from two siblings into their nearest common owner.
- Derived value
- Worked out from state on every render, never stored: counts, totals, filtered lists.
- Mirrored prop
- A prop copied into a component’s own state. It stops updating after the first render.
- Prop drilling
- Passing a prop through components that only hand it on.
- Context
- A way to hand a value to any depth without drilling. It moves values, not ownership.
Is a store an owner?Redux, Zustand, Pinia, and Svelte stores
Yes, when its writes go through its own actions. A Redux store owns its state because the
only way to change it is to dispatch an action its reducer understands, and that is where
a rule like the Doing limit can live. A store that exports a raw set or update is shared memory with a subscription: it solves prop drilling and
gives up the one thing ownership was for. The board’s store build in this lesson is that
second kind. Unidirectional data flow is
the lesson about the first kind.
03 / Follow one move
Watch the same drag on three boards.
First the build where each column copied its cards. Then the build with one global store. Then one owner with one move. The same card goes to Doing each time; then someone reaches for the Move to menu. Step through at your own pace, or open Try it and move cards yourself.
Who owns where a card is?
Each column keeps a copy
HeaderTo do 3 · Doing 2/3 · Done 1
To do 3
c1Write release notesc2Fix login redirectc3Update pricing page
Doing 2/3
c4Migrate avatarsc5Review onboarding copy
Done 1
c6Set up CI cache
Nothing moving
Six cards, and Doing has room for one more.
The header reads To do 3 · Doing 2/3 · Done 1. Each column copied its cards when it first rendered.
Reduced motion: choose a scene to see its completed state.
Read this scene
The header reads To do 3 · Doing 2/3 · Done 1. Each column copied its cards when it first rendered.
Each column keeps a copy. Six cards, and Doing has room for one more. Header: To do 3 · Doing 2/3 · Done 1. To do 3: c1, c2, c3. Doing 2/3: c4, c5. Done 1: c6.
Watch restarts the story when you come back. Step through keeps your step. Try it starts all three builds fresh each time you open it.
04 / Read the shape
The owner holds the list. Everyone else holds a door.
Basic form is the rule itself: move a card, or say why not. In the wild is the owner: Board holds the list and the one move, and the header derives its counts. At the call site the events arrive: a drop on a column, and a choice in a menu
four components down. Both are doors into the same function.
The TypeScript is real components running on a small stand-in for React’s hooks, included in
the complete file. Its state hook reads its initial value on the first render
only, as useState does, and that one rule is what makes a copied prop go stale in
the story. The Go side models the same contract with a struct; it has no components to render.
The rule every move goes through: a card moves, or the reason it did not. It returns a new list and never edits the old one. Both sides run the same rule.
export type MoveResult = 'moved' | 'refused: limit' | 'refused: unknown';
export interface Card {
readonly id: string;
readonly title: string;
readonly column: string;
readonly assignee: string;
}
export const columns = ['todo', 'doing', 'done'] as const;
export const labels: Record<string, string> = { todo: 'To do', doing: 'Doing', done: 'Done' };
export const limits: Record<string, number> = { doing: 3 };
/**
* The only rule for moving a card. It returns a new list, grouped by column,
* or the reason nothing changed. Whoever owns the list calls it.
*/
export function moveCard(
cards: readonly Card[],
id: string,
to: string,
index?: number
): { result: MoveResult; cards: readonly Card[] } {
const card = cards.find((item) => item.id === id);
if (!card || !columns.includes(to as (typeof columns)[number]))
return { result: 'refused: unknown', cards };
const inTarget = cards.filter((item) => item.column === to && item.id !== id);
if (card.column !== to && to in limits && inTarget.length >= limits[to])
return { result: 'refused: limit', cards };
const at = index === undefined ? inTarget.length : Math.max(0, Math.min(index, inTarget.length));
inTarget.splice(at, 0, { ...card, column: to });
return {
result: 'moved',
cards: columns.flatMap((column) =>
column === to ? inTarget : cards.filter((item) => item.column === column && item.id !== id)
)
};
} type Card struct {
ID string `json:"id"`
Title string `json:"title"`
Column string `json:"column"`
Assignee string `json:"assignee"`
}
var Columns = []string{"todo", "doing", "done"}
var Labels = map[string]string{"todo": "To do", "doing": "Doing", "done": "Done"}
var Limits = map[string]int{"doing": 3}
const (
Moved = "moved"
RefusedLimit = "refused: limit"
RefusedUnknown = "refused: unknown"
)
// MoveCard is the only rule for moving a card. It returns a new list, grouped
// by column, or the reason nothing changed. index < 0 means "at the end".
func MoveCard(cards []Card, id, to string, index int) (string, []Card) {
at := slices.IndexFunc(cards, func(c Card) bool { return c.ID == id })
if at < 0 || !slices.Contains(Columns, to) {
return RefusedUnknown, cards
}
card := cards[at]
var target []Card
for _, c := range cards {
if c.Column == to && c.ID != id {
target = append(target, c)
}
}
if limit, ok := Limits[to]; ok && card.Column != to && len(target) >= limit {
return RefusedLimit, cards
}
if index < 0 || index > len(target) {
index = len(target)
}
card.Column = to
target = slices.Insert(target, index, card)
next := make([]Card, 0, len(cards))
for _, column := range Columns {
if column == to {
next = append(next, target...)
continue
}
for _, c := range cards {
if c.Column == column && c.ID != id {
next = append(next, c)
}
}
}
return Moved, next
} The behavior these examples promiseChecked by 12 shared scenarios in TypeScript and Go
- A move names a card, a column, and optionally a position. An unknown card or column is
refused: unknownand changes nothing. - Moving a card into Doing when Doing already holds three is
refused: limitand changes nothing. Reordering inside Doing is always allowed. - A position past the end of the column lands the card last. The old list is never edited; a move returns a new one, grouped by column.
- Each column’s title and the header are worked out from the list the page is showing. In the owner build they come from the same list, so they always agree.
Every expected result in the shared cases was produced by a separate model written from
these rules, kept beside the examples in model/cases.py, not copied from
either implementation. It models the copies and store builds too.
The two other buildsEach column keeps a copy · one global store
Both run on the same stand-in. The copies build keeps Board’s list for the header and
moves but lets each column mirror its cards prop so a drop inside the column can
reorder instantly. The store build has no owner component: the drop handler checks the limit,
and the card menu writes the store itself.
/**
* Build one: the board owns the list, but each column copies its `cards`
* prop into its own state so a drop inside the column can reorder instantly.
* The copy is made on the first render and never refreshed.
*/
export function MirrorColumn(
{ column, cards, move }: { column: string; cards: readonly Card[]; move: Move },
use: Hooks
): Node {
const [items, setItems] = use.state(cards);
const drop = (id: string, index?: number): MoveResult => {
if (items.some((item) => item.id === id)) {
setItems(reorder(items, id, index));
return 'moved';
}
return move(id, column, index);
};
return host(
`column:${column}`,
title(column, items.length),
{ drop },
items.map((card) => component(CardView, { card, move }, card.id))
);
} /**
* Build two: one global store, so nothing is passed down. The column's drop
* handler applies the limit. The card menu, four levels down, imports the
* store and writes the card's column itself.
*/
export function StoreBoard({ store }: { store: Store<readonly Card[]> }, use: Hooks): Node {
const cards = use.store(store);
return host('board', '', {}, [
component(Header, { cards }, 'header'),
...columns.map((column) =>
component(
StoreColumn,
{ column, cards: cards.filter((card) => card.column === column), store },
column
)
)
]);
}
function StoreColumn(props: {
column: string;
cards: readonly Card[];
store: Store<readonly Card[]>;
}): Node {
const { column, cards, store } = props;
const drop = (id: string, index?: number) => {
const next = moveCard(store.get(), id, column, index);
if (next.result === 'moved') store.set(next.cards);
return next.result;
};
return host(
`column:${column}`,
title(column, cards.length),
{ drop },
cards.map((card) => component(StoreCard, { card, store }, card.id))
);
}
function StoreCard({ card, store }: { card: Card; store: Store<readonly Card[]> }): Node {
return host(`card:${card.id}`, card.title, {}, [component(StoreMenu, { card, store }, 'menu')]);
}
function StoreMenu({ card, store }: { card: Card; store: Store<readonly Card[]> }): Node {
const choose = (to: string): MoveResult => {
store.set(store.get().map((item) => (item.id === card.id ? { ...item, column: to } : item)));
return 'moved';
};
return host(`menu:${card.id}`, 'Move to', { choose });
} Reading the TypeScriptHooks, keys, and a returned list
use.state(props.initialCards) behaves like useState: the
argument is read once, when Board first renders. Children are keyed by card id, so the
stand-in keeps a card’s own state with that card when the list reorders. moveCard returns a new array rather than editing the old one, which is what lets
an owner compare old and new and re-render.
Reading the GoA struct owner and the slices package
Board keeps its cards unexported, so nothing outside the type can change
them except through Move. slices.Insert and slices.Clone keep the list work short. Copies and Store at the bottom of the file model the other two builds explicitly, since
Go has no render loop to make a copy go stale on its own.
Run it yourselfNo dependencies
Copy the complete TypeScript file and run node --experimental-strip-types board.ts with Node 22.18 or later. For Go, save main.go next to this go.mod and run go run .. Both print:
module state-ownership
go 1.22
drag c2 to doing: moved · To do 2 · Doing 3/3 · Done 1 menu c1 to doing: refused: limit · Doing is full (3 of 3). Finish something first. doing: c2 Fix login redirect, c4 Migrate avatars, c5 Review onboarding copy every card shown once: yes
05 / Review the agent’s diff
“Removed prop drilling.”
Passing move through Column and CardView to reach the menu is a real annoyance, and
an agent asked to tidy the board will often go after it. Read what this change gives the menu
before you decide.
06 / How it fails
A component tree fails by disagreeing with itself.
There is no network in this lesson, but the failure vocabulary still fits. A copy goes stale. A rule gets bypassed. A card shows twice or not at all. Local state resets when it should not. Here is each one for the board, what the person using it sees, and what the owner build does.
| What goes wrong | What the person sees | What the owner build does | Backed by |
|---|---|---|---|
| A copy goes stale | The header says Doing 3/3; the Doing column lists two; the card is still under To do. | Nothing keeps a copy. Every column derives from the one list on every render. | Case “drag one card to Doing” |
| A second door skips the rule | Doing shows 4/3. | The menu calls the same move as the drop, so both are refused. | Case “the menu fills Doing past its limit twice” |
| A move the rule refuses | A message; nothing moves. | Returns the reason and the unchanged list. | Case “drag a third card in, then a fourth” |
| A card or column that no longer exists | Nothing moves; a short message. | refused: unknown, no change. | Cases “a card id nobody has”, “a column nobody has” |
| Local state resets | A half-typed title vanishes when another card moves. | Keys each card by its id, so its state follows it. | Checker questions 4 and 5 |
| State kept in the wrong owner | The filter resets on reload; a shared link shows everyone’s cards. | Reads the filter from the URL. | Checker question 6 |
When the list comes from a server rather than living in the page, each of these gets a network twin: a stale cache, a double submit, a lost reply. Server state in the client picks that up, and Race conditions in UI covers the replies that arrive in the wrong order.
07 / Is it worth it?
You pay in props or a context. Here is what it buys.
One owner costs something: the move function has to reach every component that moves a card, through props or a context. Hold the three builds up against the kinds of change a board always gets.
| Change | Columns keep copies | Global store | One owner |
|---|---|---|---|
| A second way in: a keyboard shortcut that moves the focused card | Another handler that updates Board but not the columns’ copies. | Another writer that must remember the limit. | Calls move. The limit comes with it. |
| Replace the drag library | Each column’s drop code changes. | Each column’s drop code changes. | Each column’s drop code changes. No difference here. |
| Change a rule: a limit on every column | One change in Board, if every move reaches it. | One change per writer, found by searching. | One change in move. |
| A second team adds a card detail panel that edits titles | Their edits reach Board; the columns show the old title. | They import the store and write; nothing tells them the rules. | They get move and a rename from the owner, and the owner’s tests
cover them. |
Before you move state to one owner, decide what you will look at, and what result you would accept:
- Writers to the list. Count the places that call
setCards,store.set, orupdateon the cards. The target is one. Take the count before and after. - Copies of the list. Count component state initialized from a prop. The
target is zero, or only props named
initial…, which say they ignore updates. - Reports of the page disagreeing with itself: a card in two columns, a count that does not match. Take the rate before and after; the accepted result is none that trace back to a copy.
This page did not run the board for a real team, so it has no numbers to give you. The two counts above can be taken today with a search, which is the point: decide the question before the change, not after.
08 / Ask for it
Two prompts, two boards, one checker.
We sent two agents the same request for this board at the same time, both running Claude Sonnet. One prompt described the board. The other added an Architecture block: the board owns the list and the only move function, nothing keeps a copy or stores a count, every way to move goes through that function, a card’s unsaved edit stays with the card, and the filter lives in the URL. Then a script opened each build in Chromium and did the same seven things to it.
| What the checker did | Plain prompt | Architecture prompt |
|---|---|---|
| Drag a card to Doing | Moved once; every count agrees | Moved once; every count agrees |
| Drag a fourth card into a full Doing | Refused | Refused |
| Send a fourth card with the Move to select | Refused | Refused |
| An unsaved title edit while another card moves | Lost | Kept |
| The same, with focus kept in the title input | Lost | Kept |
| Filter by ana, then reload | Lost | Kept, in the URL (/?assignee=ana) |
| Move a card while filtered, then clear the filter | Moved once; every count agrees | Moved once; every count agrees |
The plain prompt did not build the failures from section 03. Both agents gave the card list one owner, and both sent the drag and the Move to select through one move that enforced the limit. A list that three components read and one rule guards is a well-known shape, and both agents reached for it.
The builds split on the state nobody called shared. Start typing a new title on one card, move another card, and the plain build throws the typing away. Its render rebuilds every card from the list, and the half-typed title lived only in the input it replaced. The checker ran that twice, the second time without moving focus, because the plain build also cancels an edit on blur; the draft was lost both times. Pick a filter and reload, and the plain build forgets it: the filter was a variable in a module.
function render() {
const cards = state.getSnapshot();
const visibleCards = currentFilter
? cards.filter((c) => c.assignee === currentFilter)
: cards;
const counts = { todo: 0, doing: 0, done: 0 };
visibleCards.forEach((c) => {
counts[c.column] = (counts[c.column] ?? 0) + 1;
});
headerRoot.replaceChildren(createHeader(counts));
boardRoot.replaceChildren(
createBoard(visibleCards, {
onMove: handleMove,
onEditTitle: handleEditTitle,
}),
);
} // Ownership: per-card "unsaved title edit" state.
//
// This is intentionally NOT part of the card list in store.js. It belongs
// to one card, keyed by that card's id, and must survive other cards
// moving or the whole board re-rendering. Keeping it in its own module
// (rather than on the card object itself) means the board's re-render
// never resets or loses it, and moving other cards never touches it.
const drafts = new Map(); // cardId -> in-progress title string The architecture prompt named an owner for both: state that belongs to one card stays with that card, keyed by the card’s id, and the filter is owned by the URL. Those are the lines the plain prompt lacked. The list was never the risk in these two runs. The risk was everything around it that looked too small to own.
How the runs were made and checkedOne run each, recorded as written
- Both agents received the prompts word for word, in fresh contexts, at the same time. Neither was told about the other, this lesson, or the checker. The only differences were the Architecture block and the output folder.
- The files each agent wrote are kept byte for byte, with checksums, beside this lesson’s examples. The checker restores them into a temporary folder, starts a fresh server for every question, and drives a new Chromium page with Playwright.
- The checker’s first run found the plain build’s draft gone but could not say why, since that build also cancels on blur. The second run added the focus-kept question. Both runs are kept.
- Neither agent opened its board in a browser. Both checked that the server answered with
curland that every file parsed. - This is one sample of each prompt, not a measurement of a model. Another run could land on a copied column or a store, as the section 05 diff does.
09 / Hold it there
Make a second writer hard to add by accident.
A prompt gets you one owner once. The next change, yours or an agent’s, can add a second writer in a line, as the diff in section 05 did. Three kinds of check keep the owner where you put it.
The framework’s own door
Svelte’s compiler warns when a component copies a prop into its own state. We compiled
let items = $state(cards)with the Svelte 5.57 in this repository and it printedstate_referenced_locally: “This reference only captures the initial value ofcards.” React has no warning for a mirrored prop; its docs ask you to name such a propinitial…ordefault…so a reader knows updates are ignored (Choosing the State Structure). Treat the warning as an error in CI, and treat an unprefixed mirror as a review comment.An import rule an agent cannot argue with
If the list lives in a store, only the owner may import it. This rule, run with dependency-cruiser 18.3 against a three-file copy of the section 05 diff, reported the menu’s import and nothing else. Enforcement layer runs rules like this against real code, and Architecture as rules writes them from one declaration.
.dependency-cruiser.cjs // .dependency-cruiser.cjs module.exports = { forbidden: [ { name: 'only-the-board-owns-the-cards', comment: 'The card list has one owner. Other components get move() from it, not the store.', severity: 'error', from: { pathNot: '^src/board/Board\\.' }, to: { path: '^src/stores/board\\.' } } ] };depcruise output error only-the-board-owns-the-cards: src/board/CardMenu.js → src/stores/board.js x 1 dependency violations (1 errors, 0 warnings). 3 modules, 2 dependencies cruised.A check on what actually happens
Import rules see imports, not a second
setCardscall inside the owner’s own file. So test the behavior: send a fourth card into Doing through every door, and assert it is refused, and that each column’s title agrees with the header after every move. The lesson’s spec does that for every shared case, and the checker in section 08 does it to the recorded builds in a browser.check-runs.mjs /** Does every column's heading and the header agree with the cards actually listed? */ function consistency(view) { const problems = []; const all = Object.values(view.columns).flat(); if (new Set(all).size !== all.length) problems.push('a card is listed twice'); for (const [column, ids] of Object.entries(view.columns)) { if (firstNumber(view.counts[column]) !== ids.length) problems.push(`${column} heading says "${view.counts[column]}" for ${ids.length} cards`); const inHeader = new RegExp(`${LABELS[column]}\\D{0,12}?(\\d+)`, 'i').exec(view.header); if (!inHeader || Number(inHeader[1]) !== ids.length) problems.push(`header says "${view.header}" with ${ids.length} in ${column}`); } return problems; }
Where this lives in React and SvelteEvery useState you lifted already follows this rule. A context is where it gets tested.
Where it already is in your components
The first time two siblings needed the same value, you moved a useState or a $state up into their parent and passed the value down with a setter. That is
the whole rule, and React’s docs teach it as lifting state up. The count in a heading that
you compute with .filter().length instead of storing is the other half: a
derived value, in {…} in React or $derived in Svelte.
You also already pick owners you do not think of as state: the URL for a search box on a results page, the form element for a text field you read on submit, the server for anything saved.
When you have to own it
Now the board, with the menu four components down. Threading move through
Column and CardView is tiresome, and a store would end it. Hand down the owner’s function
through a context instead: createContext in React, setContext in
Svelte. The menu calls useMove() and gets a door into the rule, not a way
around it. The context carries move, never setCards.
Keys decide who owns a card’s own state. Key the list by card.id, and a
half-typed title stays with its card when another card moves. Key it by position, and it
jumps to whichever card now sits there.
Lifting state up. The list and the heading both need the cards, so their parent owns them and passes down the list; the Doing count is derived on every render.
import { useState } from 'react';
type Card = { id: string; title: string; column: 'todo' | 'doing' | 'done' };
// Two siblings need the same cards: the column that lists them and the header
// that counts them. So neither keeps a copy. Their nearest common parent owns
// the list and passes down what each one needs.
export function Board({ initialCards }: { initialCards: Card[] }) {
const [cards, setCards] = useState(initialCards);
const doing = cards.filter((card) => card.column === 'doing');
function start(id: string) {
setCards((current) =>
current.map((card) => (card.id === id ? { ...card, column: 'doing' } : card))
);
}
return (
<main>
<h2>Doing {doing.length}/3</h2>
<ul>
{cards
.filter((card) => card.column === 'todo')
.map((card) => (
<li key={card.id}>
{card.title} <button onClick={() => start(card.id)}>Start</button>
</li>
))}
</ul>
</main>
);
}
10 / Make the call
Keep state as low as it can go, and no lower.
Start local. A value only one component reads stays in that component: an open menu, a hover, a half-typed title. Lift it when a second component needs it, and lift it only as far as their nearest common parent. A global store earns its place for state that really is app-wide and changes through named actions, such as the signed-in user or a theme.
Reopen the decision when a value gets a second writer, when a count and a list start to disagree, or when a copy needs a “sync from props” effect to stay current. Each of those says the value has two owners.
Take it with you
Explain it without saying “state ownership”: “The board keeps the one list of cards, and it is the only thing that moves them. The columns just show their part of that list, and the menus ask the board.” Then open the last UI an agent built for you and search for state initialized from a prop.
Paste into your next prompt, and fill in the blanks
<Component> owns <the shared state> and the only function that changes it. Everything that shows it works it out from that one copy on every render; no component copies it into its own state, and no count or total is stored. Every way to change it (<a drop, a menu, a shortcut>) calls that function, which enforces <the rule>. Pass the function down through context if it would cross more than <two> components. Never hand out the setter or the store itself. State that belongs to one <item> stays in that item's component, keyed by its id. <The filter or view setting> lives in the URL.
Connections to follow nextRelated lessons
- Unidirectional data flow gives the owner named actions and a reducer, so every change can be traced and undone.
- Server state in the client is about the state your components do not own at all: the server does.
- Values and references explains why returning a new list, not editing the old one, is what lets an owner notice a change.
- Observer is the subscription underneath every store, and why a store alone does not decide who may write.
- Client–server architecture asks the same question one level up: which side owns the truth.