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.
Table of Contents
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.
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 — 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 — 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.
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
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.
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.
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: 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: 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 Type | Example | Colocate? |
|---|---|---|
| Form input values | Search query, text field, checkbox | ✅ Always |
| UI toggle state | Dropdown open, tooltip visible, accordion expanded | ✅ Always |
| Hover / focus state | isHovered, isFocused | ✅ Always |
| Local loading state | isSubmitting for a single form | ✅ Always |
| Shared selection | Selected tab that affects sibling content | ⬆️ Lift |
| Auth / user data | Current 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.
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
Identify shared state
Two or more siblings need the same data. One might write it, another reads it, or both do both.
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.
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.
// ❌ 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.
// ✅ 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
// 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.
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-renders | Only the owning component re-renders | Parent + all children re-render |
| Sharing | State is private — can't be shared | Any child can access via props |
| Complexity | Simple — self-contained component | More props, more wiring, more indirection |
| Debugging | Easy — state is right where it's used | Harder — trace props through multiple levels |
| Reusability | Component is self-sufficient | Children become controlled (more flexible but need props) |
| Risk | Duplication if two components need same data | Over-lifting causes prop drilling and perf issues |
Why Lifting Too Much Is Bad
// ❌ 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.
// ✅ 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.
Decision Framework
Use this framework every time you write useState. It takes 10 seconds and prevents architectural mistakes that take hours to refactor.
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)? │ ├─ NO → Keep lifting, pass via props │ └─ YES → Consider Context or composition │ ├─ MANY distant components across the tree │ └─ 🌐 Context: React.createContext + Provider │ │ │ └─ Does it change very frequently? (typing, scrolling) │ ├─ NO → Context is fine (auth, theme, locale) │ └─ YES → State 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
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.
Who writes this state?
Which components trigger updates? The writer and all readers must be descendants of the state owner.
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 Category | Change Frequency | Placement | Example |
|---|---|---|---|
| UI micro-state | Very high | Colocate in component | Input value, hover, tooltip open |
| Feature state | Medium | Lift to feature root | Selected tab, filter, sort order |
| Shared UI state | Medium | Lift or Context | Modal open, sidebar collapsed |
| App-wide state | Low | Context or state manager | Auth, theme, locale, permissions |
| Server state | Low | React Query / SWR | API 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.
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
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
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
searchQuery + sortBy → colocated in SearchSection
Only SearchSection and its children need these. Typing in search no longer re-renders the cart or category filter.
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.
isCartOpen → colocated in CartSection
Opening/closing the cart only re-renders the cart area. The product grid and search are untouched.
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.
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.
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.
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.
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.
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.
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
// 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.
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} />.
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.
Practice Section
Work through these scenarios to build intuition for state placement decisions. Try to answer before reading the solution.
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.
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.
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.
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.
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.
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.