State ColocationLifting StateComponent ArchitecturePerformanceReact Patterns

State Colocation & Lifting State

Learn where state should live in a React app. Master the art of colocating state for performance and lifting it for sharing — the architectural decision that separates clean codebases from messy ones.

28 min read12 sections
01

Overview

Every piece of state in a React app lives somewhere in the component tree. Where you put it determines how many components re-render when it changes, how easy the code is to understand, and how many bugs you'll ship.

State colocation means keeping state as close as possible to the component that uses it. If only a form input needs a value, that state belongs inside the form — not in a global store. Lifting state up means moving state to the nearest common ancestor when multiple components need to read or write the same data.

These two principles are opposite forces that you balance on every feature. Colocate by default for performance and simplicity. Lift only when sharing is genuinely needed. This balance is what interviewers probe when they ask about React architecture.

Why this matters

State placement is the single biggest lever for React performance. Moving one useState from a top-level component to a child can eliminate thousands of unnecessary re-renders — no memo, no useCallback, no library needed.

02

The Problem: Poor State Placement

Most React performance problems aren't caused by slow algorithms or heavy DOM operations. They're caused by state living in the wrong place — too high, too low, or duplicated.

⬆️

State Too High

State in a top-level component re-renders the entire subtree on every change. A search input in App re-renders the header, sidebar, footer — everything.

⬇️

State Too Low

When two siblings need the same data but each has its own copy, they go out of sync. Duplicated state is a bug factory.

🌐

State Everywhere

Global state for everything (Redux for form inputs, Context for hover states) makes every update expensive and every component coupled to the store.

state-too-high.jsxjavascript
// ❌ State too high — search input state in App

function App() {
  const [searchQuery, setSearchQuery] = useState("");
  const [user, setUser] = useState(null);

  // Every keystroke in the search box re-renders EVERYTHING:
  // Header, Sidebar, MainContent, Footer, all their children...
  return (
    <div>
      <Header user={user} />
      <Sidebar />
      <MainContent>
        <SearchBar
          query={searchQuery}
          onChange={setSearchQuery}
        />
        <SearchResults query={searchQuery} />
      </MainContent>
      <Footer />
    </div>
  );
}

// Typing "react" = 5 keystrokes = 5 full tree re-renders
// Header, Sidebar, Footer re-render 5 times for nothing.
state-duplicated.jsxjavascript
// ❌ State duplicated — two components track the same thing

function ProductPage() {
  return (
    <div>
      <ProductFilters />   {/* has its own selectedCategory state */}
      <ProductList />      {/* ALSO has its own selectedCategory state */}
    </div>
  );
}

function ProductFilters() {
  const [selectedCategory, setSelectedCategory] = useState("all");
  // User selects "Electronics"...
  return <select onChange={e => setSelectedCategory(e.target.value)}>...</select>;
}

function ProductList() {
  const [selectedCategory, setSelectedCategory] = useState("all");
  // ...but this still shows "all" — out of sync!
  return <ul>{products.filter(p => p.category === selectedCategory)...}</ul>;
}

// Two sources of truth for the same data = guaranteed bugs.

The core question

Before writing useState, ask: "Who needs this state?" If only one component needs it, colocate it there. If siblings need it, lift to their parent. If distant components need it, consider context or a state manager. The answer to this question determines your architecture.

03

State Colocation (Keep State Close)

State colocation is the principle of placing state in the component closest to where it's used. If a piece of state is only read and written by one component, it should live inside that component — not in a parent, not in context, not in a global store.

Why Colocation Works

1

Fewer re-renders

When state lives in a child, only that child re-renders when the state changes. The parent and siblings are untouched. This is the single biggest performance win in React.

2

Easier to understand

When you open a component and see its state right there, you immediately know what it manages. No tracing props through 5 levels or searching a global store.

3

Easier to delete

Colocated state is self-contained. Delete the component and its state goes with it. No orphaned reducers, no unused context providers, no dead store slices.

Before vs After Colocation

before-colocation.jsxjavascript
// ❌ BEFORE: Search state lives in the parent

function Dashboard() {
  const [searchQuery, setSearchQuery] = useState("");
  const [dashboardData, setDashboardData] = useState(null);

  // Every keystroke re-renders Dashboard + ALL children
  return (
    <div>
      <StatsPanel data={dashboardData} />     {/* re-renders! */}
      <RecentActivity />                       {/* re-renders! */}
      <SearchBar
        query={searchQuery}
        onChange={setSearchQuery}
      />
      <SearchResults query={searchQuery} />
      <NotificationBell />                     {/* re-renders! */}
    </div>
  );
}

// 5 keystrokes × 5 components = 25 component renders
// Only SearchBar and SearchResults actually need the query.
after-colocation.jsxjavascript
// ✅ AFTER: Search state colocated in its own component

function Dashboard() {
  const [dashboardData, setDashboardData] = useState(null);

  // Dashboard only re-renders when dashboardData changes
  return (
    <div>
      <StatsPanel data={dashboardData} />
      <RecentActivity />
      <SearchSection />          {/* self-contained */}
      <NotificationBell />
    </div>
  );
}

function SearchSection() {
  // State lives here — only SearchSection re-renders on keystrokes
  const [searchQuery, setSearchQuery] = useState("");

  return (
    <div>
      <SearchBar
        query={searchQuery}
        onChange={setSearchQuery}
      />
      <SearchResults query={searchQuery} />
    </div>
  );
}

// 5 keystrokes × 2 components = 10 component renders
// StatsPanel, RecentActivity, NotificationBell: 0 re-renders
// 60% fewer renders — no memo, no useCallback, just moved state.

What State Should Be Colocated?

State TypeExampleColocate?
Form input valuesSearch query, text field, checkbox✅ Always
UI toggle stateDropdown open, tooltip visible, accordion expanded✅ Always
Hover / focus stateisHovered, isFocused✅ Always
Local loading stateisSubmitting for a single form✅ Always
Shared selectionSelected tab that affects sibling content⬆️ Lift
Auth / user dataCurrent user, permissions🌐 Global

The default should be local

Start with useState inside the component. Only move state up when you have a concrete reason — another component needs it. Don't preemptively lift state "in case something needs it later." You can always lift later; it's harder to push state back down.

04

Lifting State Up (Shared State)

When two or more components need to read or write the same piece of state, that state must live in their closest common ancestor. The parent owns the state and passes it down via props. This is "lifting state up."

The Pattern

1

Identify shared state

Two or more siblings need the same data. One might write it, another reads it, or both do both.

2

Find the closest common ancestor

Move the state to the nearest parent that contains all components needing the data. Don't go higher than necessary.

3

Pass state and setter via props

The parent passes the state value and the updater function down as props. Children become 'controlled' — they don't own the state, they just use it.

lifting-state-example.jsxjavascript
// ❌ BEFORE: Each input has its own state — they can't sync

function TemperatureConverter() {
  return (
    <div>
      <CelsiusInput />
      <FahrenheitInput />
    </div>
  );
}

function CelsiusInput() {
  const [celsius, setCelsius] = useState("");
  return <input value={celsius} onChange={e => setCelsius(e.target.value)} />;
}

function FahrenheitInput() {
  const [fahrenheit, setFahrenheit] = useState("");
  return <input value={fahrenheit} onChange={e => setFahrenheit(e.target.value)} />;
}

// Problem: typing in Celsius doesn't update Fahrenheit.
// They're independent — no shared state.
lifting-state-fixed.jsxjavascript
// ✅ AFTER: State lifted to parent — both inputs stay in sync

function TemperatureConverter() {
  // Single source of truth: temperature in Celsius
  const [celsius, setCelsius] = useState("");

  const fahrenheit = celsius ? ((parseFloat(celsius) * 9/5) + 32).toFixed(1) : "";

  const handleCelsiusChange = (value) => setCelsius(value);
  const handleFahrenheitChange = (value) => {
    setCelsius(value ? ((parseFloat(value) - 32) * 5/9).toFixed(1) : "");
  };

  return (
    <div>
      <TemperatureInput
        label="Celsius"
        value={celsius}
        onChange={handleCelsiusChange}
      />
      <TemperatureInput
        label="Fahrenheit"
        value={fahrenheit}
        onChange={handleFahrenheitChange}
      />
    </div>
  );
}

// Child is now "controlled" — it doesn't own state
function TemperatureInput({ label, value, onChange }) {
  return (
    <label>
      {label}:
      <input value={value} onChange={e => onChange(e.target.value)} />
    </label>
  );
}

// Type in either input → both update.
// Single source of truth → no sync bugs.

Another Example: Tab Selection

tabs-lifted-state.jsxjavascript
// State lifted to parent because TabBar and TabContent
// both need to know which tab is active

function TabPanel() {
  const [activeTab, setActiveTab] = useState("overview");

  return (
    <div>
      {/* TabBar writes the state (user clicks a tab) */}
      <TabBar active={activeTab} onSelect={setActiveTab} />

      {/* TabContent reads the state (shows the right panel) */}
      <TabContent active={activeTab} />
    </div>
  );
}

function TabBar({ active, onSelect }) {
  return (
    <nav>
      {["overview", "details", "reviews"].map(tab => (
        <button
          key={tab}
          className={active === tab ? "active" : ""}
          onClick={() => onSelect(tab)}
        >
          {tab}
        </button>
      ))}
    </nav>
  );
}

function TabContent({ active }) {
  switch (active) {
    case "overview": return <Overview />;
    case "details":  return <Details />;
    case "reviews":  return <Reviews />;
  }
}

// activeTab lives in TabPanel (closest common ancestor).
// TabBar writes it, TabContent reads it. Clean and minimal.

Closest common ancestor, not highest

Lift state to the nearest parent that contains all consumers. If TabBar and TabContent are siblings inside TabPanel, the state goes in TabPanel — not in App, not in a global store. Going higher than necessary causes unnecessary re-renders in unrelated components.

05

Trade-offs

Colocation and lifting are opposing forces. Every state placement decision is a trade-off between isolation and sharing, performance and convenience. Understanding these trade-offs is what separates junior from senior thinking.

Colocate (Keep Low)Lift (Move Up)
Re-rendersOnly the owning component re-rendersParent + all children re-render
SharingState is private — can't be sharedAny child can access via props
ComplexitySimple — self-contained componentMore props, more wiring, more indirection
DebuggingEasy — state is right where it's usedHarder — trace props through multiple levels
ReusabilityComponent is self-sufficientChildren become controlled (more flexible but need props)
RiskDuplication if two components need same dataOver-lifting causes prop drilling and perf issues

Why Lifting Too Much Is Bad

over-lifted-state.jsxjavascript
// ❌ Everything lifted to App — the "god component" anti-pattern

function App() {
  // App manages state for EVERY feature
  const [user, setUser] = useState(null);
  const [searchQuery, setSearchQuery] = useState("");
  const [isModalOpen, setIsModalOpen] = useState(false);
  const [selectedTab, setSelectedTab] = useState("home");
  const [tooltipVisible, setTooltipVisible] = useState(false);
  const [formData, setFormData] = useState({});
  const [dropdownOpen, setDropdownOpen] = useState(false);
  // ... 20 more useState calls

  // EVERY state change re-renders App and ALL children.
  // Typing in search re-renders the tooltip.
  // Opening a dropdown re-renders the form.
  // Nothing is isolated.

  return (
    <div>
      <Header
        user={user}
        searchQuery={searchQuery}
        onSearchChange={setSearchQuery}
        dropdownOpen={dropdownOpen}
        onDropdownToggle={setDropdownOpen}
      />
      <Sidebar selectedTab={selectedTab} onTabSelect={setSelectedTab} />
      <Main
        formData={formData}
        onFormChange={setFormData}
        isModalOpen={isModalOpen}
        onModalToggle={setIsModalOpen}
        tooltipVisible={tooltipVisible}
        onTooltipToggle={setTooltipVisible}
      />
    </div>
  );
}
// This is prop drilling hell + performance disaster.
properly-distributed.jsxjavascript
// ✅ State distributed to where it belongs

function App() {
  // App only manages truly global state
  const [user, setUser] = useState(null);

  return (
    <div>
      <Header user={user} />       {/* Header owns its own dropdown/search state */}
      <Sidebar />                   {/* Sidebar owns selectedTab */}
      <Main />                      {/* Main owns modal, form, tooltip state */}
    </div>
  );
}

function Header({ user }) {
  const [searchQuery, setSearchQuery] = useState("");   // colocated
  const [dropdownOpen, setDropdownOpen] = useState(false); // colocated
  return (/* ... */);
}

function Sidebar() {
  const [selectedTab, setSelectedTab] = useState("home"); // colocated
  return (/* ... */);
}

function Main() {
  const [isModalOpen, setIsModalOpen] = useState(false);  // colocated
  return (/* ... */);
}

// Typing in search only re-renders Header.
// Opening modal only re-renders Main.
// Each feature is isolated. Zero prop drilling.

The balancing act

The goal isn't to colocate everything or lift everything. It's to put each piece of state at the lowest level that satisfies all its consumers. If only one component needs it, colocate. If siblings need it, lift to their parent. If distant components need it, consider context.

06

Decision Framework

Use this framework every time you write useState. It takes 10 seconds and prevents architectural mistakes that take hours to refactor.

State Placement Decision Treetext
Who needs this state?

    ├─ Only ONE component
    │   └─ ✅ Colocate: useState inside that component

    ├─ TWO+ siblings (or parent + child)
    │   └─ ⬆️ Lift: useState in their closest common ancestor
    │       │
    │       └─ Is it causing prop drilling (3+ levels)?
    │           ├─ NOKeep lifting, pass via props
    │           └─ YESConsider Context or composition

    ├─ MANY distant components across the tree
    │   └─ 🌐 Context: React.createContext + Provider
    │       │
    │       └─ Does it change very frequently? (typing, scrolling)
    │           ├─ NOContext is fine (auth, theme, locale)
    │           └─ YESState manager (Zustand, Jotai) or
split into multiple contexts

    └─ Server data (API responses)
        └─ 📡 Server state library: React Query, SWR
            (not component state at all)

The Three Questions

1

Who reads this state?

List every component that displays this data. If it's just one, colocate. If it's multiple, find their common ancestor.

2

Who writes this state?

Which components trigger updates? The writer and all readers must be descendants of the state owner.

3

How often does it change?

High-frequency state (typing, mouse position, scroll) should be as low as possible to minimize re-render blast radius. Low-frequency state (theme, auth) can live higher.

State CategoryChange FrequencyPlacementExample
UI micro-stateVery highColocate in componentInput value, hover, tooltip open
Feature stateMediumLift to feature rootSelected tab, filter, sort order
Shared UI stateMediumLift or ContextModal open, sidebar collapsed
App-wide stateLowContext or state managerAuth, theme, locale, permissions
Server stateLowReact Query / SWRAPI data, cache, loading states

The golden rule

Start local, lift as needed. Every useState should begin inside the component that uses it. Only move it up when a second component genuinely needs the same data. This approach is always safe — you can lift later, but pushing state back down is a painful refactor.

07

Real-World Example

Let's build a product page with search, filters, and a shopping cart. We'll start with a bad implementation and refactor it using colocation and lifting.

❌ Bad: Everything in One Component

product-page-bad.jsxjavascript
function ProductPage() {
  // ALL state in one place
  const [searchQuery, setSearchQuery] = useState("");
  const [selectedCategory, setSelectedCategory] = useState("all");
  const [sortBy, setSortBy] = useState("price");
  const [cartItems, setCartItems] = useState([]);
  const [isCartOpen, setIsCartOpen] = useState(false);
  const [hoveredProduct, setHoveredProduct] = useState(null);
  const [quickViewProduct, setQuickViewProduct] = useState(null);

  const filtered = products
    .filter(p => p.name.includes(searchQuery))
    .filter(p => selectedCategory === "all" || p.category === selectedCategory)
    .sort((a, b) => a[sortBy] - b[sortBy]);

  return (
    <div>
      <SearchBar query={searchQuery} onChange={setSearchQuery} />
      <CategoryFilter
        selected={selectedCategory}
        onChange={setSelectedCategory}
      />
      <SortDropdown value={sortBy} onChange={setSortBy} />
      <ProductGrid
        products={filtered}
        hoveredProduct={hoveredProduct}
        onHover={setHoveredProduct}
        onQuickView={setQuickViewProduct}
        onAddToCart={(p) => setCartItems([...cartItems, p])}
      />
      <CartSidebar
        items={cartItems}
        isOpen={isCartOpen}
        onToggle={() => setIsCartOpen(!isCartOpen)}
        onRemove={(id) => setCartItems(cartItems.filter(i => i.id !== id))}
      />
      {quickViewProduct && (
        <QuickViewModal
          product={quickViewProduct}
          onClose={() => setQuickViewProduct(null)}
        />
      )}
    </div>
  );
}

// Problems:
// 1. Hovering a product re-renders the cart, search, filters
// 2. Typing in search re-renders the cart and hover state
// 3. Opening cart re-renders the entire product grid
// 4. 7 useState calls = 7 reasons for the whole tree to re-render

✅ Good: State Properly Distributed

product-page-good.jsxjavascript
function ProductPage() {
  // Only state that MULTIPLE children need lives here
  const [selectedCategory, setSelectedCategory] = useState("all");
  const [cartItems, setCartItems] = useState([]);

  const addToCart = useCallback((product) => {
    setCartItems(prev => [...prev, product]);
  }, []);

  const removeFromCart = useCallback((id) => {
    setCartItems(prev => prev.filter(i => i.id !== id));
  }, []);

  return (
    <div>
      {/* SearchSection owns its own query state */}
      <SearchSection
        category={selectedCategory}
        onAddToCart={addToCart}
      />

      <CategoryFilter
        selected={selectedCategory}
        onChange={setSelectedCategory}
      />

      {/* CartSection owns isOpen state */}
      <CartSection items={cartItems} onRemove={removeFromCart} />
    </div>
  );
}

// Search state: colocated — only SearchSection needs it
function SearchSection({ category, onAddToCart }) {
  const [searchQuery, setSearchQuery] = useState("");  // colocated!
  const [sortBy, setSortBy] = useState("price");       // colocated!

  const filtered = useMemo(() =>
    products
      .filter(p => p.name.includes(searchQuery))
      .filter(p => category === "all" || p.category === category)
      .sort((a, b) => a[sortBy] - b[sortBy]),
    [searchQuery, category, sortBy]
  );

  return (
    <div>
      <SearchBar query={searchQuery} onChange={setSearchQuery} />
      <SortDropdown value={sortBy} onChange={setSortBy} />
      <ProductGrid products={filtered} onAddToCart={onAddToCart} />
    </div>
  );
}

// Hover + quickView: colocated inside ProductGrid
function ProductGrid({ products, onAddToCart }) {
  const [hoveredId, setHoveredId] = useState(null);       // colocated!
  const [quickViewId, setQuickViewId] = useState(null);    // colocated!

  return (
    <div className="grid">
      {products.map(p => (
        <ProductCard
          key={p.id}
          product={p}
          isHovered={hoveredId === p.id}
          onHover={setHoveredId}
          onQuickView={setQuickViewId}
          onAddToCart={onAddToCart}
        />
      ))}
      {quickViewId && (
        <QuickViewModal
          product={products.find(p => p.id === quickViewId)}
          onClose={() => setQuickViewId(null)}
        />
      )}
    </div>
  );
}

// Cart open/close: colocated — only CartSection needs it
function CartSection({ items, onRemove }) {
  const [isOpen, setIsOpen] = useState(false);  // colocated!

  return (
    <>
      <button onClick={() => setIsOpen(!isOpen)}>
        Cart ({items.length})
      </button>
      {isOpen && <CartSidebar items={items} onRemove={onRemove} />}
    </>
  );
}

What Changed

1

searchQuery + sortBy → colocated in SearchSection

Only SearchSection and its children need these. Typing in search no longer re-renders the cart or category filter.

2

hoveredProduct + quickViewProduct → colocated in ProductGrid

Hover state is the most frequent update. Colocating it means hovering a product only re-renders the grid — not the entire page.

3

isCartOpen → colocated in CartSection

Opening/closing the cart only re-renders the cart area. The product grid and search are untouched.

4

selectedCategory + cartItems → lifted to ProductPage

These are genuinely shared: category affects both the filter UI and the product list. Cart items are needed by both the grid (add) and the cart sidebar (display/remove).

The result

From 7 state variables in one component to 4 components each owning 1–2 state variables. Hovering a product went from re-rendering the entire page to re-rendering just the grid. Search keystrokes went from 7 component re-renders to 3. No memo needed for most of it — just better state placement.

08

Performance Insights

State placement is the highest-leverage performance optimization in React. It's free (no runtime cost), simple (just move code), and often eliminates the need for React.memo, useMemo, and useCallback entirely.

✓ Done

Colocate Before You Memoize

Before adding React.memo or useMemo, check if the state can be moved closer to where it's used. Colocation eliminates re-renders at the source — memoization just hides them.

✓ Done

High-Frequency State Must Be Low

State that changes on every keystroke, mouse move, or scroll tick should live in the lowest possible component. The re-render blast radius must be tiny for frequent updates.

✓ Done

Use Composition to Avoid Lifting

The children-as-props pattern lets you keep state low without lifting. The parent re-renders but children passed as props don't — they were created by the grandparent.

→ Could add

Split Context by Update Frequency

Don't put fast-changing and slow-changing state in the same context. A ThemeContext (changes rarely) and a SearchContext (changes on every keystroke) should be separate providers.

→ Could add

Consider State Managers for Complex Cases

Libraries like Zustand or Jotai allow components to subscribe to specific slices of state. Only components reading the changed slice re-render — no prop drilling, no context overhead.

The Composition Pattern

composition-pattern.jsxjavascript
// Problem: ExpensiveTree re-renders when count changes
// Solution 1: React.memo (works but adds complexity)
// Solution 2: Composition (simpler, zero runtime cost)

// ❌ Before: ExpensiveTree is a child of the stateful component
function Page() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      <ExpensiveTree />  {/* re-renders every click! */}
    </div>
  );
}

// ✅ After: Extract stateful part, pass ExpensiveTree as children
function Page() {
  return (
    <Counter>
      <ExpensiveTree />  {/* created here, never re-renders! */}
    </Counter>
  );
}

function Counter({ children }) {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      {children}  {/* children is a prop — same reference, no re-render */}
    </div>
  );
}

// Why this works:
// <ExpensiveTree /> is created in Page (the grandparent).
// When Counter re-renders, children is the same JSX reference.
// React sees the same element → skips re-rendering ExpensiveTree.
// No memo, no useMemo, no useCallback. Just composition.

The optimization hierarchy

When a component re-renders too often, try these in order: (1) colocate state to a smaller component, (2) use the composition/children pattern, (3) add React.memo + useMemo/useCallback. Most problems are solved by step 1 or 2. Step 3 is the last resort.

09

Common Mistakes

These patterns show up constantly in codebases and interviews. Recognizing them instantly signals architectural maturity.

⬆️

Lifting everything to the top level

Putting all state in App or a root provider 'just in case' something needs it. Every state change re-renders the entire app. This is the #1 cause of slow React apps.

Start with useState in the component that uses it. Only lift when a second component genuinely needs the same data. You can always lift later.

📋

Duplicating state across components

Two components each have their own copy of the same data (e.g., selectedItem in both a list and a detail panel). They inevitably go out of sync.

Identify the single source of truth. Lift the state to the common ancestor and pass it down. One owner, multiple readers.

🌐

Overusing global state (Redux/Context for everything)

Putting form input values, modal open/close, hover states in Redux or Context. Every update notifies every subscriber. Local UI state doesn't belong in global stores.

Reserve global state for truly global data: auth, theme, locale, feature flags. Everything else should be local or lifted to the nearest ancestor.

🔗

Prop drilling instead of restructuring

Passing state through 5+ levels of components that don't use it, just to reach a deeply nested child. The intermediate components re-render for no reason.

First, try restructuring: can you move the child closer to the state? Can you use composition (children prop)? Context is the last resort for deep prop drilling.

🔄

Derived state stored as separate state

Storing both items and filteredItems as separate useState. When items changes, you have to remember to update filteredItems too — a sync bug waiting to happen.

Don't store what you can compute. Derive filteredItems from items + filter during render (or with useMemo if expensive). One source of truth, zero sync bugs.

🪞

Mirroring props into state

Using useState(props.initialValue) and expecting it to update when props change. useState only uses the initial value once — subsequent prop changes are ignored.

If you need the value to stay in sync with props, use the prop directly (controlled component). If you need a local copy that can diverge, use a key to reset: <Component key={id} />.

10

Interview Questions

These questions test your understanding of React architecture and state management. Interviewers want to hear trade-offs, not just definitions.

Q:What is state colocation and why does it matter?

A: State colocation means placing state in the component closest to where it's used. It matters because when state lives in a component, only that component re-renders when the state changes. If the same state lived in a parent, the parent and ALL its children would re-render. Colocation is the simplest and most effective React performance optimization — it reduces re-renders without any memoization.

Q:When should you lift state up?

A: Lift state when two or more components need to read or write the same data. Move the state to their closest common ancestor and pass it down via props. The classic example is two inputs that need to stay in sync — neither can own the state alone, so the parent owns it and both inputs become controlled components.

Q:How do you decide where state should live?

A: Ask three questions: (1) Who reads this state? If only one component, colocate it there. (2) Who writes this state? The writer and all readers must be descendants of the state owner. (3) How often does it change? High-frequency state (typing, hovering) should be as low as possible. Low-frequency state (auth, theme) can live higher or in context.

Q:How do you avoid prop drilling without using global state?

A: Three approaches: (1) Restructure components — move the child closer to the state owner so fewer levels need the prop. (2) Use composition — pass the consuming component as children or a render prop so intermediate components don't need the data. (3) Use React Context for data that many components at different depths need (theme, auth, locale). Context is not global state — it's scoped to a subtree.

Q:What's the difference between state colocation and lifting state?

A: They're opposite directions. Colocation pushes state DOWN to the component that uses it — optimizing for performance and isolation. Lifting pushes state UP to a common ancestor — enabling sharing between siblings. You colocate by default and lift only when sharing is needed. The skill is finding the lowest level that satisfies all consumers.

Q:Why is putting everything in Redux or Context a bad idea?

A: Global state means every update potentially notifies every subscriber. Putting form inputs, hover states, or modal toggles in Redux causes unnecessary re-renders across the app. It also couples components to the store, making them harder to test and reuse. Reserve global state for truly global, low-frequency data: authentication, theme, locale, feature flags.

Q:What is derived state and why shouldn't you store it?

A: Derived state is data that can be computed from other state. For example, filteredItems can be computed from items + filterQuery. Storing it as separate state creates two sources of truth that can go out of sync. Instead, compute it during render: const filtered = items.filter(...). If the computation is expensive, wrap it in useMemo. One source of truth, zero sync bugs.

Q:How would you structure state in a large React application?

A: Layer it: (1) Component state (useState) for local UI — inputs, toggles, hover. (2) Lifted state for feature-level sharing — selected tab, filter, sort. (3) Context for app-wide, low-frequency data — auth, theme, locale. (4) Server state library (React Query/SWR) for API data — caching, refetching, loading states. (5) State manager (Zustand/Jotai) only if Context causes performance issues with frequent updates.

11

Practice Section

Work through these scenarios to build intuition for state placement decisions. Try to answer before reading the solution.

1

Where Should This State Live?

A page has a Navbar with a search input and a MainContent area that displays search results. The search query is currently stored in App (the root). Every keystroke re-renders the entire app including the footer and sidebar. Where should the state live?

Answer: Lift the search query to the closest common ancestor of the search input and the results — likely a SearchSection component that wraps both. If Navbar and MainContent are siblings under App, create a SearchProvider or a wrapper component that owns the query. Don't keep it in App where it re-renders unrelated components like Footer and Sidebar.

2

Why Is This Causing Re-renders?

A modal component has isOpen state in the page-level component. Opening the modal re-renders a heavy data table with 10,000 rows. The table has nothing to do with the modal. Why is this happening?

Answer: The isOpen state lives in the page component. When it changes, the page re-renders, which re-renders all children — including the heavy table. Fix: colocate isOpen inside a ModalSection component that only contains the modal trigger button and the modal itself. The table is no longer a sibling of the state owner, so it won't re-render.

3

How Would You Refactor This?

A form component has 8 useState calls: name, email, phone, address, city, state, zip, country. The parent component also needs the complete form data for a preview panel. Currently all 8 states are in the parent, causing the preview to re-render on every keystroke in every field.

Answer: Two approaches: (1) Keep a single formData object in the parent (one useState instead of 8), and pass individual field values + onChange handlers to the form. The preview reads formData. (2) Better: keep all field state in the Form component and use a debounced callback to notify the parent of changes. The preview updates less frequently, and individual keystrokes only re-render the form. Use useReducer if the form logic is complex.

4

Colocation vs Context

A theme toggle (dark/light mode) is used by 30+ components across the app. Should you colocate this state or use Context?

Answer: Use Context. Theme is a classic case for Context: it's read by many distant components, changes very rarely (user clicks a toggle), and is truly app-wide. Colocating would be impossible since 30+ components need it. Lifting would cause massive prop drilling. Context with a ThemeProvider at the root is the right call. Since it changes rarely, the re-render cost of Context is negligible.

5

Derived State Bug

A component stores both products (from API) and filteredProducts (filtered by search) as separate useState. When the API returns new products, the filtered list doesn't update until the user types in the search box again. What's wrong?

Answer: filteredProducts is derived state stored separately. When products updates, filteredProducts still holds the old filtered result — it's a stale copy. Fix: don't store filteredProducts as state. Compute it during render: const filteredProducts = products.filter(p => p.name.includes(query)). Wrap in useMemo if the list is large. Now it always reflects the latest products and query — one source of truth.

12

Cheat Sheet (Quick Revision)

One-screen summary for quick revision before interviews.

Quick Revision Cheat Sheet

State colocation: Keep state in the component closest to where it's used. Default choice for all local UI state.

Lifting state: Move state to the closest common ancestor when siblings need to share it. Pass down via props.

Start local, lift as needed: Always begin with useState in the component. Only lift when a second consumer appears.

Closest common ancestor: Lift to the NEAREST parent containing all consumers — not App, not a global store.

Colocate these: Form inputs, hover/focus, tooltips, dropdowns, loading spinners, local toggles.

Lift these: Selected tab (TabBar + TabContent), shared filters, synced inputs, selection state.

Context for these: Auth, theme, locale, feature flags — app-wide, low-frequency, many consumers.

Don't store derived state: Compute filteredItems from items + filter during render. Use useMemo if expensive.

Don't mirror props into state: Use the prop directly (controlled) or use key to reset local state when prop changes.

Composition pattern: Pass expensive children as props/children to avoid re-renders without memo.

Prop drilling fix: Restructure components first, then try composition, then Context. Don't jump to global state.

High-frequency state: Typing, scrolling, hovering — must be colocated as low as possible. Tiny re-render blast radius.

Optimization order: 1. Colocate state → 2. Composition pattern → 3. React.memo + useMemo/useCallback.