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 already be comfortable with props, state snapshots, immutable updates, events, forms, keys, and basic Effects.
Mental model: every piece of state needs one owner
When a UI starts becoming difficult to reason about, do not begin by asking which state library to install. Start with a more useful question:
Where is the authoritative copy of this value?
Consider a task screen that has all of these values:
- 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.
There is no reason for these values to share a store simply because they appear on the same screen. Each has a different lifetime, set of consumers, and system that may change it.
A useful first classification looks like this:
| 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 target is not to make everything local, and it is not to make everything global. Make ownership explicit, then keep each value with the system that has the right authority over it.
Lifting state
Suppose two sibling controls need to use the same filter. If each sibling owns a separate copy, changing one copy cannot reliably update the other. Put the value in their nearest common parent instead:
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} />
</>
);
}
Here the nearest common parent owns status because both siblings need the same value. TaskFilters changes it, and TaskList consumes the filtered result.
Do not automatically lift the state all the way to App. Lift it only to a component that actually coordinates the consumers. State placed unnecessarily high can cause unrelated parts of the tree to rerender and makes the ownership boundary harder to find.
Derived state should usually remain derived
The more copies of a fact you store, the more synchronization work you create. For example, this state requires every task update to keep three values consistent:
const [tasks, setTasks] = useState([]);
const [openTasks, setOpenTasks] = useState([]);
const [openCount, setOpenCount] = useState(0);
Prefer calculating the values from the one authoritative collection:
const openTasks = tasks.filter((task) => !task.completed);
const openCount = openTasks.length;
The render already has everything required for this calculation. Adding an Effect to synchronize these values would introduce another render and another place where the values can drift apart.
A diagnostic question
Ask whether one value can always be calculated from the current props and state. If it can, storing it creates a second source of truth rather than independent information.
Values that should usually be derived include:
- 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: it records real information that cannot be reconstructed from other current values. A poor structure stores both an object and one of its identifying fields:
const [selectedTask, setSelectedTask] = useState(taskObject);
const [selectedTaskId, setSelectedTaskId] = useState(taskObject.id);
If the task object changes while its ID remains the same, these two pieces of state can disagree. Code then has to decide which copy to trust.
Store the identity and derive the current object instead:
const [selectedTaskId, setSelectedTaskId] = useState(null);
const selectedTask =
tasks.find((task) => task.id === selectedTaskId) ?? null;
Now the ID is the one authoritative selection, and selectedTask always comes from the current tasks value.
Controlled and uncontrolled components
A component is controlled for a value when its parent owns that value and supplies both the current value and a way to change it:
function AccordionItem({ open, onOpenChange, children }) {
return (
<section>
<button onClick={() => onOpenChange(!open)}>
{open ? 'Hide' : 'Show'}
</button>
{open && children}
</section>
);
}
An uncontrolled component owns its value internally. The parent supplies an initial default, not the current value:
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 contracts are legitimate. The deciding question is who must coordinate the value. Use the controlled version when a parent must open, close, or coordinate several items. Use the uncontrolled version when the component can manage its own behavior.
Avoid a component that sometimes follows internal state and sometimes follows parent state without a clear contract. That ambiguity is how controlled/uncontrolled bugs and confusing mode changes arise.
Preserving and resetting state
React preserves state when a component retains the same identity in the rendered tree. This means a changed prop does not, by itself, clear local state:
<Editor task={task} />
Changing task does not automatically reset Editor's local draft. That preservation is normally useful, but it is wrong when each task should open as a new editing session.
If changing the task should create a fresh editor, make the task ID part of the component identity:
<Editor key={task.id} task={task} />
When the ID changes, React treats the editor as a different component and remounts it, which resets its local state.
Do not generate random keys for this purpose:
<Editor key={crypto.randomUUID()} task={task} />
The random key changes on every parent render, so the editor is destroyed and recreated on every render rather than only when the task changes.
Example: editing with committed data and draft data
An editor commonly needs two deliberately different ownership concepts: committed task data and unsaved input. The parent owns the committed task, while the editor owns the temporary draft:
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 remains authoritative for the saved task. The editor is authoritative only for its temporary draft. That separation matters: cancelling can discard the draft without mutating the saved task, and saving can send an intentional update back to the parent.
Server state is not ordinary client state
A task fetched from the server is not a permanent local fact. It can become stale because:
- another user changed it;
- the server normalized it;
- a retry succeeded;
- the browser reconnected;
- another tab wrote data.
This is why later lessons use TanStack Query v5 rather than treating server responses as ordinary permanent local state.
A query cache provides concepts that plain useState does not: freshness, invalidation, refetching, retries, deduplication, and mutation lifecycle. The useful distinction is that server state has an external authority and a synchronization problem; local UI state generally does not.
URL state
A filter belongs in the URL when users should be able to bookmark it, share it, refresh it, or revisit it with Back and Forward. For example:
/tasks?status=open&page=3
is often a better owner than:
const [status, setStatus] = useState('open');
const [page, setPage] = useState(3);
when route navigation is part of the product behavior. Component state cannot provide those URL semantics on its own.
React Router is introduced later for this reason: it provides the routing boundary through which this state can be read and updated.
Common mistakes
Mirroring props into state
Avoid copying a prop into local state unless the copy intentionally represents a separate concept:
function Profile({ user }) {
const [name, setName] = useState(user.name);
}
This is valid if name is an independently editable draft. Otherwise, the local copy can become stale when user changes. Render user.name directly when the component does not need independent ownership.
Effect-based synchronization
Do not use an Effect to calculate a value that can be calculated from current inputs:
useEffect(() => {
setVisibleTasks(tasks.filter(...));
}, [tasks, filter]);
Calculate the list during render instead. Effects are for synchronizing with systems outside React, not for maintaining a redundant copy of a value React can derive.
Global state as convenience
Do not create a global store merely because passing a prop through one intermediate component feels inconvenient. First check whether composition or moving the consuming component produces a simpler ownership boundary. A global store adds a broader lifetime and more consumers; that cost should solve a real coordination problem.
Debugging state ownership
When state behaves incorrectly, make the ownership problem concrete:
- 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.
This sequence separates identity problems, duplicated state, and external synchronization problems instead of treating every symptom as a React rendering issue.
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 a component has several possible places to store a value, use the following sequence. It is not a requirement to use a particular library; it is a way to identify the authority and lifetime the value actually needs.
Step 1: Who reads it?
List every consumer before choosing an owner.
If only one component reads the value, start with local state. 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 may be read in many places but changed in only one workflow. That can still be a good fit for one clear owner. Widespread reading is not, by itself, a reason to duplicate the value into a global store.
Step 3: Does another system already own it?
Some values are already controlled by a system outside React:
current URL → router
server resource → query cache
form draft → form state
browser online status → browser external store
Do not create a React copy merely because React is capable of storing it. A second copy creates synchronization work and can lag behind the actual owner.
Step 4: Must it survive navigation/reload?
If the value must survive navigation or a full page reload, candidates include:
- URL;
- server;
- persistent storage.
Component state alone will not survive a full page reload. That lifetime requirement often decides ownership before component structure does.
Step 5: Is it derived?
If the value can be calculated from authoritative inputs, calculate it. Storing it would turn a calculation into another source of truth.
State taxonomy in a real dashboard
Imagine a dashboard at this route:
/tasks?status=open&owner=me
It may need all of these values:
tasks from API
status filter
owner filter
sidebar collapsed
new-task form draft
current user
selected row IDs
open task count
A reasonable initial ownership map is:
| 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 single "global store" containing all of these values would erase useful distinctions. The task collection has server freshness, the filters have URL semantics, the draft has form lifetime, and the count is just a calculation.
Lift state only until coordination is possible
Suppose these two components need status:
TaskFilters
TaskList
Their nearest common parent can coordinate them without making the rest of the application aware of the value:
function TaskPanel() {
const [status, setStatus] = useState('all');
return (
<>
<TaskFilters value={status} onChange={setStatus} />
<TaskList status={status} />
</>
);
}
Do not lift this state to App if App does not coordinate it. The parent shown here is high enough for both consumers and no higher than necessary.
Colocation reduces blast radius
State placed high in the tree means more descendants are involved when it updates. That may be acceptable, but keeping state close to its consumers reduces the area you need to reason about and can reduce unnecessary rendering work.
For example:
function SearchBox() {
const [draft, setDraft] = useState('');
...
}
If only SearchBox needs the draft, keep it there. Promote the committed search term later only if another component needs that committed value. The draft and the committed query may have different owners and different timing requirements.
Controlled versus uncontrolled reusable APIs
A reusable component can intentionally support both controlled and uncontrolled modes. A controlled call might look like this:
<Disclosure
open={open}
onOpenChange={setOpen}
/>
controlled.
The uncontrolled form supplies an initial value instead:
<Disclosure defaultOpen />
uncontrolled.
Supporting both correctly is an API design decision, not just a matter of adding two props. Decide:
- what happens if the caller supplies
openwithout a callback? - can mode change during the component lifetime?
- what source wins?
- how are defaults applied?
For an application-specific component, choose one mode unless the additional flexibility is genuinely needed. A narrowly defined contract is often easier to use correctly.
Resetting state by identity
Imagine a form that initializes a local draft from this prop:
<UserForm user={selectedUser} />
If selecting a different user should discard the old draft, make the selected user part of the form's identity:
<UserForm key={selectedUser.id} user={selectedUser} />
This expresses the product rule directly: a different user is a different form instance.
If drafts should survive switching users, the key reset is wrong. Store drafts keyed by user ID or move them to an owner whose lifetime spans the switch. Whether state resets is a product decision, not an incidental React detail.
State normalization
Nested client state can make updates deeply nested:
const [board, setBoard] = useState({
columns: [
{
id: 'todo',
tasks: [...]
}
]
});
For complex client-only entities, a normalized structure can make identity and updates easier to manage:
{
taskIds: ['t1', 't2'],
tasksById: {
t1: {...},
t2: {...}
}
}
Normalization is a tool for a particular client-state shape, not a reason to copy every server response into Redux. Query-cache libraries may already provide the cache architecture and lifecycle that the server data needs.
Draft versus canonical entity
There is a critical distinction between the canonical server entity and a local edit draft:
canonical server task
versus:
local edit draft
A draft is intentionally a copy. That copy is valid because it represents a new ownership concept: unsaved user edits.
const [draft, setDraft] = useState(() => ({
title: task.title,
description: task.description,
}));
Keep the distinction explicit:
task = authoritative current server view
draft = temporary local proposal
Do not continually overwrite draft whenever server data refetches while the user is typing. The refetched task and the unsaved draft may both be correct for their respective roles, so conflicts must be handled explicitly.
Conflict scenario
Consider this sequence:
- 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.
The right response depends on the product requirements:
- freeze draft until save/cancel;
- merge untouched fields;
- show conflict;
- use versioning/ETag;
- reset only when entity ID changes.
The example is why "sync props to state" is not a general-purpose solution. It hides a product decision about conflicts instead of making that decision explicit.
URL ownership and synchronization
This pattern gives both React state and the URL ownership of the same filter:
const [status, setStatus] = useState(searchParams.get('status') ?? 'all');
useEffect(() => {
setSearchParams({ status });
}, [status]);
Avoid it when the URL is meant to be authoritative. The two systems can become temporarily inconsistent, and each update now needs synchronization logic.
Prefer reading and writing the URL directly through the router. Keep one owner for the filter, rather than maintaining a React mirror of URL state.
Failure clinic
Duplicated boolean
These values may encode the same fact twice:
const [modalOpen, setModalOpen] = useState(false);
const [selectedTask, setSelectedTask] = useState(null);
If the modal should be open exactly when a task is selected, modalOpen is derived and does not need independent state:
const modalOpen = selectedTask !== null;
Selected object becomes stale
Store the ID rather than a snapshot object when the canonical task list can update. Derive the selected object from the current list so the selection follows the latest data.
Context introduced too early
If two siblings need a value, props from their nearest common parent can be simpler than introducing context. Context is useful for broader coordination, but it should not be the first response to a small prop path.
Architecture exercise
Take a page and label every value with one of these ownership categories:
L = local
P = parent/shared
U = URL
S = server
F = form draft
E = external browser/system
D = derived
Then look for values with two labels. They are likely duplicate-ownership bugs, or at least places where the design needs an explicit reason for maintaining two representations.
Deep-dive 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 brings together values with very different ownership and lifetimes:
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 these values in Redux. That gives them one storage mechanism but does not give them one coherent source of truth.
A better first ownership map is:
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
This map is a starting point, not a prohibition against using Redux. If a value truly needs cross-feature client coordination, a client store may be appropriate. The point is to decide value by value instead of defaulting to one global container.
The deciding question is not "how many components use it?"
The number of consumers is useful, but it is not the deciding question. Server tasks may be used by 50 components and still belong to the query cache because the server owns their freshness and mutations.
A form draft may be used by five nested fields and still belong to the form because those fields are editing one temporary proposal.
A URL filter may be read by only two components and still belong to the URL because users need Back/Forward, share, and reload semantics.
Ownership review during refactor
When adding a new library or moving a value to a new system, write down the migration explicitly:
| 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. Otherwise the new owner has not actually become authoritative; the application now has two values that must be synchronized.
Most state-synchronization bugs appear because a team adds a new owner without deleting the old one.
