ReactState Colocation

State Colocation

State colocation is the practice of keeping state as close as possible to the component that uses it. It is the counterpart to lifting state up: instead of always pushing state toward the root, you push it as far down the tree as it will go while still serving all its consumers.

The principle is simple: state should live at the lowest common ancestor of all the components that need it. Not higher. When you lift state unnecessarily, more of the tree re-renders than needed, and the relationship between state and the UI that uses it becomes harder to follow.

Why Colocated State Is Faster

When a piece of state changes, React re-renders the component that owns it and all of its descendants. State owned higher in the tree means a larger subtree re-renders on every update. State owned as low as possible minimises the blast radius:

JSX
// ✗ Bad: filter state lives at the top of a large tree
//
//   App (owns filterQuery state)
//   ├── Header         ← re-renders on every keystroke (doesn't use filter)
//   ├── Sidebar        ← re-renders on every keystroke (doesn't use filter)
//   └── ProductSection
//       └── FilterBar  ← re-renders (good — actually uses filter)
//           └── ProductGrid ← re-renders (good — uses filter)

function App() {
  const [filterQuery, setFilterQuery] = useState('')

  return (
    <>
      <Header />               {/* wasted render on every keystroke */}
      <Sidebar />              {/* wasted render on every keystroke */}
      <ProductSection
        filterQuery={filterQuery}
        onFilterChange={setFilterQuery}
      />
    </>
  )
}

JSX
// ✓ Good: filter state colocated in ProductSection
//
//   App
//   ├── Header         ← never re-renders when filter changes
//   ├── Sidebar        ← never re-renders when filter changes
//   └── ProductSection (owns filterQuery state)
//       └── FilterBar  ← re-renders (good)
//           └── ProductGrid ← re-renders (good)

function App() {
  return (
    <>
      <Header />
      <Sidebar />
      <ProductSection />   {/* manages its own filtering internally */}
    </>
  )
}

function ProductSection() {
  // State lives here — only this subtree re-renders on filter change
  const [filterQuery, setFilterQuery] = useState('')

  const filtered = products.filter(p =>
    p.name.toLowerCase().includes(filterQuery.toLowerCase())
  )

  return (
    <>
      <FilterBar query={filterQuery} onChange={setFilterQuery} />
      <ProductGrid products={filtered} />
    </>
  )
}

In the colocated version, typing in the filter input only re-renders ProductSection and its children. Header and Sidebar are completely unaffected.

Refactoring: Moving State Down

The most common colocation refactor is recognising that a piece of state was lifted higher than necessary. Here is a step-by-step example with a modal that was incorrectly owned at the root:

JSX
// ✗ Before: modal open state lives at the root
function App() {
  const [isDeleteModalOpen, setDeleteModalOpen] = useState(false)
  const [productToDelete, setProductToDelete]   = useState(null)

  return (
    <>
      <Navbar />        {/* never uses modal state — but re-renders when it changes */}
      <ProductList
        onDeleteRequest={(product) => {
          setProductToDelete(product)
          setDeleteModalOpen(true)
        }}
      />
      <DeleteModal
        isOpen={isDeleteModalOpen}
        product={productToDelete}
        onClose={() => setDeleteModalOpen(false)}
      />
    </>
  )
}

// ✓ After: modal state colocated in ProductList (or a dedicated container)
function App() {
  return (
    <>
      <Navbar />
      <ProductListWithDelete />   {/* self-contained — owns its own modal */}
    </>
  )
}

function ProductListWithDelete() {
  // Both the list and the modal live here — zero prop drilling, zero wasted renders
  const [isDeleteModalOpen, setDeleteModalOpen] = useState(false)
  const [productToDelete, setProductToDelete]   = useState(null)

  return (
    <>
      <ProductList
        onDeleteRequest={(product) => {
          setProductToDelete(product)
          setDeleteModalOpen(true)
        }}
      />
      <DeleteModal
        isOpen={isDeleteModalOpen}
        product={productToDelete}
        onClose={() => setDeleteModalOpen(false)}
      />
    </>
  )
}
The Golden Rule: Global State = Global Re-renders

Global state managers — whether the Context API, Redux, or Zustand — make state available everywhere. But that convenience comes at a cost: every subscriber can re-render on every update. The more state you push into a global store, the larger the surface area for unnecessary re-renders.

JSX
// ✗ Everything in the global store — UI toggle state does not belong here
const useStore = create(set => ({
  user: null,           // ✓ global — needed everywhere
  cartItems: [],        // ✓ global — needed everywhere
  products: [],         // ✓ global — needed in many places
  isSidebarOpen: false, // ✗ local UI state — only Sidebar cares about this
  activeTab: 'all',     // ✗ local UI state — only ProductTabs cares about this
  searchQuery: '',      // ✗ local UI state — only SearchBar cares about this
}))

// ✓ Keep UI toggle state local; only put genuinely shared data in global state
const useStore = create(set => ({
  user: null,
  cartItems: [],
  products: [],
}))

function Sidebar() {
  const [isOpen, setIsOpen] = useState(false)   // local, colocated
  // ...
}

function ProductTabs() {
  const [activeTab, setActiveTab] = useState('all')   // local, colocated
  // ...
}
Warning
A common anti-pattern in Redux or Zustand codebases is putting every single piece of state into the global store "for consistency." This causes the entire app to re-render on trivial UI interactions like opening a dropdown or toggling an accordion. Keep ephemeral UI state local.
When Colocated State Is the Wrong Choice

Colocation is the default, but it is not always right. Move state up (or into a shared context) when:

  • Two or more non-ancestor components need the same value — they must share a common parent, so lift to that parent.

  • A child needs to communicate a result to a sibling — lift the state to the shared parent and pass a callback down.

  • Persistence across unmount/remount is needed — colocated state is destroyed when its component unmounts. Lift it up (or use a global store) if the value must survive.

  • Deep prop drilling becomes painful — at 3+ levels, consider lifting into a Context instead of threading props.

Colocation + Custom Hooks

Custom hooks are the perfect companion for colocation. They let you keep state colocated inside a component while extracting the logic into a reusable, testable unit. The state still lives in the calling component's scope — the hook is just a way to organise the code:

JSX
// The logic is extracted but the state is still LOCAL to the caller
function useProductFilter(products) {
  const [query, setQuery]   = useState('')
  const [category, setCategory] = useState('all')

  const filtered = products.filter(p => {
    const matchesQuery    = p.name.toLowerCase().includes(query.toLowerCase())
    const matchesCategory = category === 'all' || p.category === category
    return matchesQuery && matchesCategory
  })

  return { query, setQuery, category, setCategory, filtered }
}

// ProductSection owns the state (via the hook) — colocation is preserved
function ProductSection({ products }) {
  const { query, setQuery, category, setCategory, filtered } =
    useProductFilter(products)

  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <select value={category} onChange={e => setCategory(e.target.value)}>
        <option value="all">All</option>
        <option value="electronics">Electronics</option>
      </select>
      <ProductGrid products={filtered} />
    </>
  )
}

// The hook can be reused in a different component:
function AdminProductPanel({ products }) {
  const { query, setQuery, filtered } = useProductFilter(products)
  // AdminProductPanel gets its own independent state — not shared with ProductSection
}
Tip
Think of a custom hook as a way to move code out of a component without moving state out. The state remains colocated with the component that uses it; the hook just makes the logic portable and testable in isolation.
A Mental Model for State Placement
  • Used in one component only → colocate inside that component.

  • Used in a parent and one child → colocate in the parent, pass as prop.

  • Used in two sibling components → lift to their shared parent.

  • Used in many components at different depths → use Context or a global store.

  • Ephemeral UI state (open/closed, hover, active tab) → almost always colocated, never global.

  • Shared entity state (current user, auth token, cart) → global store or Context.

Note
When in doubt, start with colocation and lift only when you feel the pain of lifting being necessary. It is much easier to lift state up than to pull it back down after it has been prematurely globalised.