Module: React and Ecosystem
React and Ecosystem·099·9 MIN READ

099: State Ownership, Lifting State, and Derived State

TOPICS COVERED: 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 valueTypical owner
temporary input draftform/component
open/closed accordionlocal component
shared wizard stepnearest shared parent
URL filter/pagerouter / URL
server task collectionquery cache / router loader
authenticated accountauth boundary/provider/server
derived countcalculation 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:

jsx
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:

jsx
const [tasks, setTasks] = useState([]);
const [openTasks, setOpenTasks] = useState([]);
const [openCount, setOpenCount] = useState(0);

Now every task change must keep three values synchronized.

Prefer:

jsx
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:

jsx
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:

jsx
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:

jsx
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:

jsx
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.

jsx
<Editor task={task} />

Changing task does not automatically reset Editor's local draft.

If changing the task should create a fresh editor:

jsx
<Editor key={task.id} task={task} />

Now the identity changes with the task ID, so React remounts the editor.

Do not generate random keys:

jsx
<Editor key={crypto.randomUUID()} task={task} />

That destroys and recreates the component on every parent render.

Example: editing with committed data and draft data

jsx
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:

text
/tasks?status=open&page=3

is often a better owner than:

jsx
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:

jsx
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:

jsx
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:

  1. write down the authoritative owner for each value;
  2. identify duplicate copies;
  3. inspect whether a key remounts unexpectedly;
  4. check whether props were copied into state;
  5. check whether an Effect is synchronizing two React values unnecessarily;
  6. check whether server data is being mirrored into multiple stores;
  7. move state to the nearest owner that genuinely coordinates the consumers.

Exercises

  1. Refactor a task list that stores openTasks and completedTasks separately so only tasks is state.
  2. Build an accordion with one open item controlled by the parent.
  3. Build an uncontrolled disclosure component with defaultOpen.
  4. Create an editor whose draft resets when the selected task ID changes using a key.
  5. Decide where each belongs: theme, page number, server tasks, toast visibility, search draft, selected team from the URL.

Exit questions

  1. What makes a value derived rather than independent state?
  2. When should state be lifted?
  3. Why is server state different from local UI state?
  4. How does a key affect state identity?
  5. What problem does a controlled component solve?
  6. Why can mirroring props into state create bugs?

Official references


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:

text
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:

text
/tasks?status=open&owner=me

Dashboard values:

text
tasks from API
status filter
owner filter
sidebar collapsed
new-task form draft
current user
selected row IDs
open task count

Potential ownership:

ValueOwner
tasksTanStack Query later
statusURL
owner filterURL
sidebar collapsedlocal/Redux preference
form draftform
current userauth/provider/server
selected IDslocal or client store
open countderived from tasks

A "global store" containing all of them would erase useful distinctions.

Lift state only until coordination is possible

Suppose:

text
TaskFilters
TaskList

need status.

Nearest common parent:

jsx
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:

jsx
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:

jsx
<Disclosure
  open={open}
  onOpenChange={setOpen}
/>

controlled.

Or:

jsx
<Disclosure defaultOpen />

uncontrolled.

Implementing both correctly requires deciding:

  • what happens if caller supplies open without 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:

jsx
<UserForm user={selectedUser} />

and local draft initialization.

If selecting a different user should discard old draft:

jsx
<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:

jsx
const [board, setBoard] = useState({
  columns: [
    {
      id: 'todo',
      tasks: [...]
    }
  ]
});

can make updates deeply nested.

For complex client-only entities, normalized structure can help:

jsx
{
  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:

text
canonical server task

versus:

text
local edit draft

A draft is intentionally a copy.

jsx
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:

text
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

  1. User opens task.
  2. User edits title locally.
  3. Background refetch returns updated server description.
  4. 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:

jsx
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

jsx
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:

jsx
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:

text
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

  1. Perform the ownership labeling exercise on a dashboard.
  2. Refactor a duplicated modal boolean.
  3. Convert selected object state to selected ID + derivation.
  4. Build an edit draft that survives background canonical-data refetch.
  5. Move a shareable filter from component state into URL state.
  6. 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:

text
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:

text
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:

CurrentNew ownerRemove old copy?
tasks useStateQueryyes
status useStateURLyes
edit title useStateRHFyes
selected IDs localReduxmaybe, 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.