Skip to content

State Colocation as Optimization

advanced11 min read

The Most Underrated Optimization

Ask a React developer how to fix unnecessary re-renders and they'll say React.memo. Ask a senior React developer and they'll say "move the state down."

// BEFORE: state at the top cascades to everything
function App() {
  const [isTooltipOpen, setIsTooltipOpen] = useState(false);

  return (
    <div>
      <Header />           {/* Re-renders when tooltip toggles */}
      <Sidebar />          {/* Re-renders when tooltip toggles */}
      <MainContent />      {/* Re-renders when tooltip toggles */}
      <HelpButton
        isOpen={isTooltipOpen}
        onToggle={() => setIsTooltipOpen(!isTooltipOpen)}
      />
      <Footer />           {/* Re-renders when tooltip toggles */}
    </div>
  );
}

// AFTER: state colocated with where it's used
function App() {
  return (
    <div>
      <Header />           {/* Never re-renders from tooltip */}
      <Sidebar />          {/* Never re-renders from tooltip */}
      <MainContent />      {/* Never re-renders from tooltip */}
      <HelpButton />       {/* Only this re-renders */}
      <Footer />           {/* Never re-renders from tooltip */}
    </div>
  );
}

function HelpButton() {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>Help</button>
      {isOpen && <Tooltip content="..." />}
    </div>
  );
}

Zero imports added. Zero APIs learned. Just moved the state to where it belongs. The performance impact can be dramatic: from re-rendering 50 components to re-rendering 2.

The Mental Model

Mental Model

Think of state as a sprinkler in a garden. A sprinkler at the top of the hill waters everything downhill — every flower, every weed, every rock. Most of that water is wasted.

State colocation is moving the sprinkler to the specific flower bed that needs it. The water (re-renders) only reaches the plants (components) that need it. The rest of the garden stays dry.

The principle: state should live as close as possible to where it's used. If only one component reads the state, the state belongs in that component. If two components read it, the state belongs in their nearest common ancestor — and no higher.

The Colocation Principle

State placement follows a clear rule:

If only ComponentA uses the state → state lives in ComponentA
If ComponentA and ComponentB use the state → state lives in their nearest common ancestor
If the whole app uses the state → state lives at the app level (or in context/external store)

Every level you move state UP beyond where it's needed creates unnecessary re-renders in sibling and cousin components.

// State used by one component — colocate IN that component
function SearchBar() {
  const [query, setQuery] = useState('');  // Only SearchBar needs this
  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

// State used by two siblings — colocate in their parent
function FilterSection() {
  const [filter, setFilter] = useState('all');  // Shared by FilterButtons and ResultCount
  return (
    <>
      <FilterButtons active={filter} onChange={setFilter} />
      <ResultCount filter={filter} />
    </>
  );
}

// State used by distant components — context or external store
// (This is the ONLY case where lifting state high is justified)
Execution Trace
Before
State in App (level 0)
setState triggers re-render of ALL 50 components in the tree
After
State in FilterSection (level 3)
setState re-renders FilterSection + 2 children = 3 components
Savings
47 fewer re-renders
94% reduction from just moving state to the right place

Pattern: Extract State Into a Wrapper

When state is coupled with a small part of the UI, extract that part:

// BEFORE: count state re-renders the expensive list
function Page() {
  const [count, setCount] = useState(0);
  const [items] = useState(() => generateExpensiveItems(1000));

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <ExpensiveList items={items} />  {/* Re-renders on every click */}
    </div>
  );
}

// AFTER: extract the counter into its own component
function Page() {
  const [items] = useState(() => generateExpensiveItems(1000));

  return (
    <div>
      <Counter />  {/* Manages its own state */}
      <ExpensiveList items={items} />  {/* Never re-renders from count changes */}
    </div>
  );
}

function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}

Pattern: Children as Props

When a parent needs state but its children don't use it:

// BEFORE: children re-render because of parent state
function ScrollTracker() {
  const [scrollY, setScrollY] = useState(0);

  useEffect(() => {
    const handler = () => setScrollY(window.scrollY);
    window.addEventListener('scroll', handler);
    return () => window.removeEventListener('scroll', handler);
  }, []);

  return (
    <div>
      <ProgressBar progress={scrollY / maxScroll} />
      <Header />        {/* Re-renders on scroll — hundreds of times! */}
      <MainContent />   {/* Re-renders on scroll */}
      <Footer />        {/* Re-renders on scroll */}
    </div>
  );
}

// AFTER: children passed from outside don't re-render
function ScrollTracker({ children }) {
  const [scrollY, setScrollY] = useState(0);

  useEffect(() => {
    const handler = () => setScrollY(window.scrollY);
    window.addEventListener('scroll', handler);
    return () => window.removeEventListener('scroll', handler);
  }, []);

  return (
    <div>
      <ProgressBar progress={scrollY / maxScroll} />
      {children}  {/* Same React element reference — skipped in reconciliation */}
    </div>
  );
}

// Usage
function Page() {
  return (
    <ScrollTracker>
      <Header />        {/* Created by Page, not ScrollTracker */}
      <MainContent />   {/* Never re-renders from scroll */}
      <Footer />        {/* Never re-renders from scroll */}
    </ScrollTracker>
  );
}

When ScrollTracker re-renders, children is the same JSX reference (created by Page, which didn't re-render). React sees the same element reference and skips reconciliation for the children subtree.

Common Trap

This pattern works because children are created in the PARENT scope (Page), not the state-owning scope (ScrollTracker). When ScrollTracker re-renders, it receives the same children prop (because Page didn't re-render). If you create elements directly inside ScrollTracker, they're new JSX objects every render.

Production Scenario: Dashboard State Audit

A team profiles their dashboard and finds 200ms render times on hover. State audit reveals:

App (level 0)
├── hoveredWidget: string    ← Used by: WidgetGrid, WidgetTooltip
├── searchQuery: string      ← Used by: SearchBar, SearchResults
├── dateRange: object        ← Used by: All 20 widgets
├── notifications: array     ← Used by: NotificationBell
├── sidebarOpen: boolean     ← Used by: Sidebar
└── selectedTab: string      ← Used by: TabBar, TabContent

All six pieces of state live in App. Any change re-renders everything. The fix:

function App() {
  const [dateRange, setDateRange] = useState(initialRange);

  return (
    <DateContext.Provider value={{ dateRange, setDateRange }}>
      <SidebarSection />       {/* Manages sidebarOpen internally */}
      <NotificationSection />  {/* Manages notifications internally */}
      <SearchSection />        {/* Manages searchQuery internally */}
      <TabSection>             {/* Manages selectedTab internally */}
        <WidgetSection />      {/* Manages hoveredWidget internally */}
      </TabSection>
    </DateContext.Provider>
  );
}

Only dateRange stays at App level (used by all widgets). Everything else moves down. Hover latency drops from 200ms to 3ms because hovering only re-renders WidgetSection, not the entire app.

When NOT to Colocate

State colocation doesn't mean "all state should be local." Sometimes state belongs higher:

// WRONG: colocating state that multiple siblings need
function FilterButtons() {
  const [filter, setFilter] = useState('all'); // ← Only FilterButtons can read this
  return <div>{/* buttons */}</div>;
}

function ResultList() {
  // Can't access filter! It's trapped in FilterButtons
  // Need to lift state to common parent
}

// CORRECT: lift to nearest common ancestor
function FilteredResults() {
  const [filter, setFilter] = useState('all');
  return (
    <>
      <FilterButtons filter={filter} onChange={setFilter} />
      <ResultList filter={filter} />
    </>
  );
}

The principle is "as low as possible, as high as necessary." If lifting state is required for the feature to work, lift it — but only to the nearest common ancestor, not to the root.

Common Mistakes

What developers doWhat they should do
Putting all state at the top 'for easy access'
Top-level state cascades re-renders to every component in the tree. State at the leaf only affects the leaf
Colocate state as close to its consumers as possible
Using context or global stores for state used by one component
Context adds Provider overhead and re-renders all consumers. Local state re-renders only the owning component
Use local useState if only one component reads the value
Assuming memo is the solution for top-heavy state
Colocation eliminates unnecessary re-renders at the source. Memo patches them after the fact — less efficient, more complex
Move state down first. Use memo only if colocation isn't possible
Creating many small components 'for colocation' when it hurts readability
Over-extracting creates too many tiny files with scattered logic. Colocate state, but keep related UI logic together
Balance colocation with code organization. Extract only when there's a clear performance or readability win

Challenge

Challenge: Audit and colocate the state

// This component tree has state placement issues.
// Identify which state should move where.

function App() {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  const [modalOpen, setModalOpen] = useState(false);
  const [formData, setFormData] = useState({});
  const [tooltipTarget, setTooltipTarget] = useState(null);

  return (
    <div className={theme}>
      <Navbar user={user} />
      <Settings
        theme=`{theme}`
        onThemeChange=`{setTheme}`
      />
      <MainContent>
        <Form data={formData} onChange={setFormData} />
        <SubmitButton onClick={() => setModalOpen(true)} />
      </MainContent>
      <Tooltip target={tooltipTarget} />
      `{modalOpen && <Modal onClose={() => setModalOpen(false)}` />}
    </div>
  );
}

// Questions:
// 1. Which state values should stay in App?
// 2. Which should move down? To where?
Show Answer

State audit:

  1. user — STAYS in App. Used by Navbar and potentially Form (permissions). Must be at the top.

  2. theme — STAYS in App. Applied to the root <div> className. Used by the whole tree.

  3. modalOpen — MOVE to a wrapper around SubmitButton + Modal. Only SubmitButton (open) and Modal (close) interact with it. Move to their parent:

function SubmitSection() {
  const [modalOpen, setModalOpen] = useState(false);
  return (
    <>
      <SubmitButton onClick={() => setModalOpen(true)} />
      {modalOpen && <Modal onClose={() => setModalOpen(false)} />}
    </>
  );
}
  1. formData — MOVE to a FormSection wrapper. Only Form uses it:
function FormSection() {
  const [formData, setFormData] = useState({});
  return <Form data={formData} onChange={setFormData} />;
}
  1. tooltipTarget — MOVE to a TooltipManager. Only Tooltip reads it, and tooltip targets set it via event handlers:
function TooltipManager({ children }) {
  const [target, setTarget] = useState(null);
  return (
    <TooltipContext.Provider value={setTarget}>
      {children}
      <Tooltip target={target} />
    </TooltipContext.Provider>
  );
}

After colocation, App only holds user and theme. Tooltip changes, modal toggles, and form input no longer re-render the entire app.

Quiz

Quiz
Why is state colocation more effective than React.memo for preventing unnecessary re-renders?

Key Rules

Key Rules
  1. 1State should live as close as possible to where it's used. If only one component reads it, it belongs in that component.
  2. 2State colocation eliminates re-renders at the source. memo patches them after the fact — colocation is always more efficient.
  3. 3The children-as-props pattern prevents re-renders without memo: children created by a stable parent are the same reference.
  4. 4Extract stateful logic into wrapper components to isolate re-render scope without adding memo.
  5. 5Lift state to the nearest common ancestor when multiple components need it — no higher.
  6. 6Audit state placement regularly. As features evolve, state tends to drift upward. Push it back down.