099: State Ownership, Lifting State, and Derived State
Learning objectives
By the end of this lesson you should be able to:
- decide which component should own a value;
- distinguish local state, shared client state, URL state, and server state;
- lift state only as high as necessary;
- keep derived values out of state;
- identify duplicated or contradictory sources of truth;
- design controlled and uncontrolled component contracts;
- reset state intentionally with structure or keys.
Prerequisites
Complete 088–098. You should understand props, state snapshots, immutable updates, events, forms, keys, and basic Effects.
Mental model: every piece of state needs one owner
When a UI becomes difficult to reason about, ask:
Where is the authoritative copy of this value?
Consider a task screen with:
- the list of tasks returned by the server;
- the current search text;
- the selected status filter in the URL;
- whether a modal is open;
- a form draft;
- the authenticated user.
These values do not automatically belong in the same store.
A useful classification is:
| Kind of value | Typical owner |
|---|---|
| temporary input draft | form/component |
| open/closed accordion | local component |
| shared wizard step | nearest shared parent |
| URL filter/page | router / URL |
| server task collection | query cache / router loader |
| authenticated account | auth boundary/provider/server |
| derived count | calculation during render |
The goal is not “put everything local” or “put everything global.” The goal is to make ownership explicit.
Lifting state
Suppose two sibling controls need the same filter:
function TaskScreen({ tasks }) {
const [status, setStatus] = useState('all');
const visibleTasks = tasks.filter((task) => {
if (status === 'open') return !task.completed;
if (status === 'done') return task.completed;
return true;
});
return (
<>
<TaskFilters value={status} onChange={setStatus} />
<TaskList tasks={visibleTasks} />
</>
);
}
The state is owned by their nearest common parent because both siblings need the same value.
Do not lift the state all the way to App unless App or other branches actually need it. Excessively high state causes unrelated parts of the tree to rerender and makes ownership harder to see.
Derived state should usually remain derived
Avoid this:
const [tasks, setTasks] = useState([]);
const [openTasks, setOpenTasks] = useState([]);
const [openCount, setOpenCount] = useState(0);
Now every task change must keep three values synchronized.
Prefer:
const openTasks = tasks.filter((task) => !task.completed);
const openCount = openTasks.length;
The render already has everything required to calculate these values.
A diagnostic question
If one value can always be calculated from other current props/state, storing it creates a second source of truth.
Common examples that should usually be derived:
- filtered arrays;
- totals and counts;
- full names from first/last name;
- whether a submit button is disabled;
- whether a list is empty;
- formatted display values.
State structure
Good state is minimal and represents real independent information.
Poor structure:
const [selectedTask, setSelectedTask] = useState(taskObject);
const [selectedTaskId, setSelectedTaskId] = useState(taskObject.id);
If the object can change while the ID remains the same, the two can disagree.
Prefer:
const [selectedTaskId, setSelectedTaskId] = useState(null);
const selectedTask =
tasks.find((task) => task.id === selectedTaskId) ?? null;
This keeps one authoritative identity.
Controlled and uncontrolled components
A component is controlled for a value when its parent owns the value:
function AccordionItem({ open, onOpenChange, children }) {
return (
<section>
<button onClick={() => onOpenChange(!open)}>
{open ? 'Hide' : 'Show'}
</button>
{open && children}
</section>
);
}
An uncontrolled version owns its own state:
function Disclosure({ defaultOpen = false, children }) {
const [open, setOpen] = useState(defaultOpen);
return (
<section>
<button onClick={() => setOpen((current) => !current)}>
{open ? 'Hide' : 'Show'}
</button>
{open && children}
</section>
);
}
Both can be valid. The question is who must coordinate the value.
Avoid components that sometimes use internal state and sometimes parent state without a clear contract.
Preserving and resetting state
React preserves state when component identity remains the same.
<Editor task={task} />
Changing task does not automatically reset Editor's local draft.
If changing the task should create a fresh editor:
<Editor key={task.id} task={task} />
Now the identity changes with the task ID, so React remounts the editor.
Do not generate random keys:
<Editor key={crypto.randomUUID()} task={task} />
That destroys and recreates the component on every parent render.
Example: editing with committed data and draft data
function TaskEditor({ task, onSave, onCancel }) {
const [draft, setDraft] = useState(task.title);
function submit(event) {
event.preventDefault();
const title = draft.trim();
if (title.length < 3) return;
onSave({
...task,
title,
});
}
return (
<form onSubmit={submit}>
<label htmlFor={`task-${task.id}`}>Task title</label>
<input
id={`task-${task.id}`}
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
<button>Save</button>
<button type="button" onClick={onCancel}>
Cancel
</button>
</form>
);
}
The parent owns the committed task. The editor owns the temporary draft.
This separation is useful because cancelling should not mutate the saved task.
Server state is not ordinary client state
A server task can become stale because:
- another user changed it;
- the server normalized it;
- a retry succeeded;
- the browser reconnected;
- another tab wrote data.
That is why later lessons use TanStack Query v5 rather than treating server responses as permanent local state.
A query cache understands freshness, invalidation, refetching, retries, deduplication, and mutation lifecycle. A plain useState does not.
URL state
A filter that users should be able to bookmark, share, refresh, or navigate with Back/Forward usually belongs in the URL.
For example:
/tasks?status=open&page=3
is often a better owner than:
const [status, setStatus] = useState('open');
const [page, setPage] = useState(3);
if route navigation is part of the product behavior.
React Router is introduced later for this reason.
Common mistakes
Mirroring props into state
Avoid:
function Profile({ user }) {
const [name, setName] = useState(user.name);
}
unless name is intentionally an independent editable draft.
Otherwise the local copy can become stale when user changes.
Effect-based synchronization
Avoid:
useEffect(() => {
setVisibleTasks(tasks.filter(...));
}, [tasks, filter]);
Calculate the list during render.
Global state as convenience
Do not create a global store just because passing a prop through one intermediate component feels annoying. First ask whether composition or moving the consuming component is simpler.
Debugging state ownership
When state behaves incorrectly:
- write down the authoritative owner for each value;
- identify duplicate copies;
- inspect whether a key remounts unexpectedly;
- check whether props were copied into state;
- check whether an Effect is synchronizing two React values unnecessarily;
- check whether server data is being mirrored into multiple stores;
- move state to the nearest owner that genuinely coordinates the consumers.
Exercises
- Refactor a task list that stores
openTasksandcompletedTasksseparately so onlytasksis state. - Build an accordion with one open item controlled by the parent.
- Build an uncontrolled disclosure component with
defaultOpen. - Create an editor whose draft resets when the selected task ID changes using a
key. - Decide where each belongs: theme, page number, server tasks, toast visibility, search draft, selected team from the URL.
Exit questions
- What makes a value derived rather than independent state?
- When should state be lifted?
- Why is server state different from local UI state?
- How does a key affect state identity?
- What problem does a controlled component solve?
- Why can mirroring props into state create bugs?
Official references
- https://react.dev/learn/sharing-state-between-components
- https://react.dev/learn/choosing-the-state-structure
- https://react.dev/learn/preserving-and-resetting-state
- https://react.dev/learn/managing-state
Deep dive: state ownership as a design algorithm
When deciding where state belongs, use this sequence.
Step 1: Who reads it?
List every consumer.
If only one component reads it, start local.
If siblings need it, consider their nearest common owner.
If unrelated branches need it, consider context or an external store.
Step 2: Who changes it?
A value read everywhere but changed in one workflow may still have one clear owner.
Step 3: Does another system already own it?
Examples:
current URL → router server resource → query cache form draft → form state browser online status → browser external store
Do not create a React copy just because React can store it.
Step 4: Must it survive navigation/reload?
If yes, candidates include:
- URL;
- server;
- persistent storage.
Component state alone will not survive a full page reload.
Step 5: Is it derived?
If yes, calculate it.
State taxonomy in a real dashboard
Imagine:
/tasks?status=open&owner=me
Dashboard values:
tasks from API status filter owner filter sidebar collapsed new-task form draft current user selected row IDs open task count
Potential ownership:
| Value | Owner |
|---|---|
| tasks | TanStack Query later |
| status | URL |
| owner filter | URL |
| sidebar collapsed | local/Redux preference |
| form draft | form |
| current user | auth/provider/server |
| selected IDs | local or client store |
| open count | derived from tasks |
A "global store" containing all of them would erase useful distinctions.
Lift state only until coordination is possible
Suppose:
TaskFilters TaskList
need status.
Nearest common parent:
function TaskPanel() {
const [status, setStatus] = useState('all');
return (
<>
<TaskFilters value={status} onChange={setStatus} />
<TaskList status={status} />
</>
);
}
Do not lift to App if App does not coordinate it.
Colocation reduces blast radius
State placed high means more descendants are called on each update.
That may be fine, but local state can improve both reasoning and performance.
Example:
function SearchBox() {
const [draft, setDraft] = useState('');
...
}
If only SearchBox needs the draft, keep it there.
Later, only promote the committed search term if other components need it.
Controlled versus uncontrolled reusable APIs
A robust component may intentionally support both modes.
Example API design:
<Disclosure
open={open}
onOpenChange={setOpen}
/>
controlled.
Or:
<Disclosure defaultOpen />
uncontrolled.
Implementing both correctly requires deciding:
- what happens if caller supplies
openwithout callback? - can mode change during component lifetime?
- what source wins?
- how are defaults applied?
For application-specific components, choose one mode unless flexibility is actually needed.
Resetting state by identity
Imagine:
<UserForm user={selectedUser} />
and local draft initialization.
If selecting a different user should discard old draft:
<UserForm key={selectedUser.id} user={selectedUser} />
This expresses "different user, different form identity."
If drafts should survive switching users, then key reset is wrong; state must be keyed/stored elsewhere.
State reset is a product decision.
State normalization
Nested client state:
const [board, setBoard] = useState({
columns: [
{
id: 'todo',
tasks: [...]
}
]
});
can make updates deeply nested.
For complex client-only entities, normalized structure can help:
{
taskIds: ['t1', 't2'],
tasksById: {
t1: {...},
t2: {...}
}
}
But do not normalize query-cache data into Redux merely because normalization is a known pattern. Server-state libraries may already provide the cache architecture you need.
Draft versus canonical entity
A critical distinction:
canonical server task
versus:
local edit draft
A draft is intentionally a copy.
const [draft, setDraft] = useState(() => ({
title: task.title,
description: task.description,
}));
The copy is valid because it represents a new ownership concept: unsaved user edits.
Document this mentally:
task = authoritative current server view draft = temporary local proposal
Do not continually overwrite draft when server data refetches while the user is typing.
Handle conflicts explicitly.
Conflict scenario
- User opens task.
- User edits title locally.
- Background refetch returns updated server description.
- If you run an Effect
setDraft(task)on every task change, user title is overwritten.
Solutions depend on product needs:
- freeze draft until save/cancel;
- merge untouched fields;
- show conflict;
- use versioning/ETag;
- reset only when entity ID changes.
This is why "sync props to state" is not a generic solution.
URL ownership and synchronization
Avoid:
const [status, setStatus] = useState(searchParams.get('status') ?? 'all');
useEffect(() => {
setSearchParams({ status });
}, [status]);
Now React state and URL state both own the filter.
Prefer reading/writing the URL directly through the router.
One owner.
Failure clinic
Duplicated boolean
const [modalOpen, setModalOpen] = useState(false);
const [selectedTask, setSelectedTask] = useState(null);
If modal should be open exactly when a task is selected, modalOpen may be derived:
const modalOpen = selectedTask !== null;
Selected object becomes stale
Store ID rather than a snapshot object if canonical list can update.
Context introduced too early
If two siblings need a value, nearest parent props can be simpler.
Architecture exercise
Take a page and label every value with:
L = local P = parent/shared U = URL S = server F = form draft E = external browser/system D = derived
Then identify values with two labels. Those are likely duplicate ownership bugs.
Exercises
- Perform the ownership labeling exercise on a dashboard.
- Refactor a duplicated modal boolean.
- Convert selected object state to selected ID + derivation.
- Build an edit draft that survives background canonical-data refetch.
- Move a shareable filter from component state into URL state.
- Compare state colocation before/after with React Profiler.
Mastery check
Explain:
- how to choose an owner;
- why colocation matters;
- when copying data into a draft is correct;
- why URL and server data are special owners;
- how key reset encodes identity;
- why duplicated ownership causes synchronization code.
Production case study: deciding ownership in a collaborative task board
A collaborative task board contains:
current route boardId server columns/tasks search/filter dragging item selected row IDs open details panel edit draft current account permissions realtime connection toast queue
A naive architecture puts all of it in Redux.
A better first ownership map:
route + boardId → Router columns/tasks → TanStack Query server cache search/filter → URL if shareable dragging coordinates → local/ref selected row IDs → local or client store details panel → URL or local depending shareability edit draft → form current account projection → auth context/query/server permissions → server-derived query/auth model realtime socket instance → Effect/ref/service toast queue → local/provider/store
The deciding question is not "how many components use it?"
Server tasks may be used by 50 components, yet they still belong to the query cache.
A form draft may be used by five nested fields, yet it still belongs to the form.
A URL filter may be read by only two components, yet it belongs to the URL because users need Back/Forward/share/reload semantics.
Ownership review during refactor
When adding a new library, create a migration table:
| Current | New owner | Remove old copy? |
|---|---|---|
tasks useState | Query | yes |
status useState | URL | yes |
| edit title useState | RHF | yes |
| selected IDs local | Redux | maybe, only if cross-feature |
A migration is incomplete until the previous source of truth is removed.
Most "state synchronization bugs" happen because the team added a new owner without deleting the old one.
