Home/Learn/React/Structuring State — Where It Lives and What Belongs In It

Structuring State — Where It Lives and What Belongs In It

Intermediate
State & Effects

Most React bugs are state in the wrong place or state that should not exist. Deciding both correctly removes whole categories of bug before they happen.

Overview

Two questions decide the shape of a React application. First, does this need to be state at all — because anything derivable from existing state or props should be computed during render, not stored and synchronised. Second, which component should own it — the answer being the closest common ancestor of everything that reads it, and no higher. Getting these right is worth more than any amount of memoisation: duplicated state that can disagree with itself is the source of the bugs users describe as "it showed the old value".

Do Not Store What You Can Derive

Redundant state is state that can be wrong. Compute instead.

Store ids and raw data; compute the rest
// Redundant — two sources of truth that can drift apart
const [problems, setProblems] = useState([])
const [count, setCount] = useState(0)              // derivable
const [hasResults, setHasResults] = useState(false) // derivable
const [selected, setSelected] = useState(null)      // storing the whole object

// Derived during render — cannot be stale, by construction
const [problems, setProblems] = useState([])
const [selectedId, setSelectedId] = useState(null)

const count = problems.length
const hasResults = problems.length > 0
const selected = problems.find(p => p.id === selectedId)

// Store the ID, not the object. If the object is refetched and
// updated, a stored copy silently becomes the old version.

// "But it recalculates every render" — filtering a few hundred rows
// is microseconds. Measure before caching it with useMemo.

Lifting State Up

Move state to the closest common ancestor of everything that reads it — no higher.

The ladder from local to global
// Two siblings need the same value, so it moves to the parent
function ProblemsPage() {
  const [filter, setFilter] = useState('all')      // owns it

  return (
    <>
      <FilterBar value={filter} onChange={setFilter} />
      <ProblemList filter={filter} />
    </>
  )
}

// Lifting too high is also a bug. State at the top of the app that
// only one leaf uses means every state change re-renders everything
// between them.

// The ladder, in order of preference:
//   local useState              -> as long as one component needs it
//   lifted to a common parent   -> when siblings need it
//   URL search params           -> filters, tabs, pagination
//   context                     -> genuinely app-wide (theme, session)
//   a store (Zustand/Redux)     -> complex shared state across routes

// Filters and pagination belong in the URL, not in state — the view
// then survives a refresh and can be shared.

Grouping Related State

Independent booleans allow impossible combinations. A single value cannot.

One status value beats three booleans
// Three booleans -> eight combinations, five of them nonsense
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
// isLoading && isError is representable, and will eventually happen

// One value -> exactly four valid states
const [state, setState] = useState({ status: 'idle' })
// { status: 'idle' }
// { status: 'loading' }
// { status: 'success', data }
// { status: 'error', error }

if (state.status === 'loading') return <Spinner />
if (state.status === 'error')   return <Error msg={state.error} />

// This is the discriminated union from the TypeScript track, applied
// to component state — and it is the single highest-value habit in
// this whole category.

// Values that always change together belong in one object:
const [position, setPosition] = useState({ x: 0, y: 0 })

Key Points to Remember

  • 1Anything derivable from existing state or props should be computed during render, not stored
  • 2Store an id rather than a copy of an object, so a refetch cannot leave a stale duplicate
  • 3Put state in the closest common ancestor of its readers — lifting too high re-renders the whole subtree
  • 4Filters, tabs and pagination belong in the URL, where they survive a refresh and can be shared
  • 5Replace independent booleans with one status value so impossible combinations cannot be represented

Interview Questions

Sign in to ask Aria
1

How do you decide which component should own a piece of state?

Medium
2

Why is storing a filtered copy of a list in state usually a bug?

Medium
3

What is wrong with separate isLoading, isError and isSuccess flags?

Medium

Ask Aria about Structuring State — Where It Lives and What Belongs In It

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…