← Architecture
Frontend applications Who holds the state

State ownership in a component tree

Each piece of state has one owner.

You have lifted state up. You have also passed a prop through four components that did nothing with it but hand it on, and wondered whether a store would be simpler. Both moves are answers to one question: which component owns this? Let’s ask an agent for a project board and watch what happens to the cards when that question goes unanswered.

TypeScriptGoOne board, three builds, two recorded agent runs.

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.

Who owns each piece of the board’s state
StateOwnerWhy
Which cards exist, and where each one isBoardThe columns, the header, and every card menu read it. Board is the nearest component above all of them.
The Doing limitBoard’s moveA rule about a change belongs with the one function that makes the change.
Each column’s count, and the headerNobody: derivedWorked out from the list on every render. A stored count is a second copy that can disagree.
An unsaved title editThat cardNobody 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 openCardMenuLocal and short-lived. Lifting it would make the board re-render for nothing.
The assignee filterThe URLA 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.

State ownership

Who owns where a card is?

Each column keeps a copy

HeaderTo do 3 · Doing 2/3 · Done 1

To do 3

  • c1 Write release notes
  • c2 Fix login redirect
  • c3 Update pricing page

Doing 2/3

  • c4 Migrate avatars
  • c5 Review onboarding copy

Done 1

  • c6 Set up CI cache

Nothing moving

01/ 03
Each column keeps a copy

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.

TypeScriptReading
board.ts
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)
		)
	};
}
GoAlongside
main.go
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: unknown and changes nothing.
  • Moving a card into Doing when Doing already holds three is refused: limit and 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.

builds.ts · each column keeps a copy
/**
 * 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))
	);
}
builds.ts · one global store
/**
 * 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:

go.mod
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.

The agent’s pull request

“Removed prop drilling: CardMenu updates the board store directly, so move no longer passes through Column and CardView. All 31 tests pass.”

// Column.tsx
			(removed)<CardView key={card.id} card={card} move={move} />
			(added)<CardView key={card.id} card={card} />
			
			// CardView.tsx
			(removed)<CardMenu card={card} move={move} />
			(added)<CardMenu card={card} />
			
			// CardMenu.tsx
			(added)import { boardStore } from '../stores/board';
			(removed)onChange={(e) => move(card.id, e.target.value)}
			(added)onChange={(e) => boardStore.update((cards) =>
			(added)  cards.map((c) => (c.id === card.id ? { ...c, column: e.target.value } : c))
			(added))}
			
You are reviewing this change. What do you do?

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.

Failure modes of one card move
What goes wrongWhat the person seesWhat the owner build doesBacked by
A copy goes staleThe 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 ruleDoing 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 refusesA 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 existsNothing moves; a short message.refused: unknown, no change.Cases “a card id nobody has”, “a column nobody has”
Local state resetsA 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 ownerThe 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.

The same four changes, made to each build
ChangeColumns keep copiesGlobal storeOne owner
A second way in: a keyboard shortcut that moves the focused cardAnother 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 libraryEach 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 columnOne 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 titlesTheir 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, or update on 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 found, run 2026-09-23
What the checker didPlain promptArchitecture prompt
Drag a card to DoingMoved once; every count agreesMoved once; every count agrees
Drag a fourth card into a full DoingRefusedRefused
Send a fourth card with the Move to selectRefusedRefused
An unsaved title edit while another card movesLostKept
The same, with focus kept in the title inputLostKept
Filter by ana, then reloadLostKept, in the URL (/?assignee=ana)
Move a card while filtered, then clear the filterMoved once; every count agreesMoved 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.

js/main.js · plain prompt
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,
    }),
  );
}
js/editState.js · architecture prompt
// 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 curl and 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.

  1. 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 printed state_referenced_locally: “This reference only captures the initial value of cards.” React has no warning for a mirrored prop; its docs ask you to name such a prop initial… or default… 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.

  2. 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.
  3. A check on what actually happens

    Import rules see imports, not a second setCards call 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.

ReactAlready in your code
Board.tsx
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

Take the board into your editor. Add a keyboard shortcut that moves the focused card one column right, and make it go through the owner’s move.

Back to architecture →