Episode 2 — React Frontend Architecture NextJS / 2.6 — Component Architecture Principles
2.6.c — Lifting State Up
In one sentence: When two sibling components need to share data, move the state to their closest common parent and pass it down as props — this is React's fundamental data-sharing pattern.
Navigation: ← Smart vs Dumb Components · Next → Prop Drilling Problem
Table of Contents
- The Problem: Siblings Can't Talk Directly
- What "Lifting State Up" Means
- Step-by-Step: Temperature Converter
- The Pattern in Detail
- Case Study: Search + Results
- Case Study: Multi-Panel Dashboard
- Case Study: Form with Live Preview
- Identifying Where to Lift
- Lifting State with Callbacks
- Performance Considerations
- When Lifting Goes Too High
- Lifting State vs Other Solutions
- Common Mistakes
- Key Takeaways
1. The Problem: Siblings Can't Talk Directly
In React, data flows in one direction: parent → child via props. There is no built-in way for sibling components to communicate directly.
┌──────────┐
│ Parent │
│ │
└──┬────┬──┘
│ │
props│ │props
│ │
▼ ▼
┌────────┐ ┌────────┐
│ Child A│ │ Child B│
│ │ │ │
│ Has │ │ Needs │
│ data │ │ data │
└────────┘ └────────┘
❌ No direct path from A → B
Example: The Problem
function App() {
return (
<div>
<SearchInput /> {/* Has the search query */}
<SearchResults /> {/* Needs the search query */}
</div>
);
}
function SearchInput() {
const [query, setQuery] = useState(''); // State lives here
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
function SearchResults() {
// ❌ How does SearchResults know what the user typed?
// It can't access SearchInput's state!
return <div>Results for: ???</div>;
}
The state (query) is trapped inside SearchInput. SearchResults has no way to access it because React components can't reach into each other's state.
2. What "Lifting State Up" Means
Solution: Move the shared state to the nearest common ancestor (parent) of both components, then pass it down as props.
BEFORE: AFTER:
┌──────────┐ ┌──────────────┐
│ Parent │ │ Parent │
│ (no state)│ │ query state │◄── State lifted HERE
└──┬────┬──┘ └──┬────────┬──┘
│ │ │ │
▼ ▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ Input │ │ Results│ │ Input │ │ Results│
│ query ◄┘ │ ??? │ │ props: │ │ props: │
│ (own │ │ │ │ query │ │ query │
│ state) │ └────────┘ │onChange│ │ │
└────────┘ └────────┘ └────────┘
// ✅ State lifted to the parent
function App() {
const [query, setQuery] = useState(''); // State lives in parent now
return (
<div>
<SearchInput query={query} onQueryChange={setQuery} />
<SearchResults query={query} />
</div>
);
}
function SearchInput({ query, onQueryChange }) {
// No local state — receives everything from parent
return <input value={query} onChange={e => onQueryChange(e.target.value)} />;
}
function SearchResults({ query }) {
// Now has access to the query!
return <div>Results for: {query}</div>;
}
The Three Steps
- Remove state from the child component
- Add state to the nearest common parent
- Pass state down as props (and pass a setter/callback for updates)
3. Step-by-Step: Temperature Converter
The classic React tutorial example: two temperature inputs (Celsius and Fahrenheit) that stay in sync.
Step 1: Two Independent Inputs (Broken)
// ❌ Each input has its own state — they can't sync
function CelsiusInput() {
const [temp, setTemp] = useState('');
return (
<label>
Celsius: <input value={temp} onChange={e => setTemp(e.target.value)} />
</label>
);
}
function FahrenheitInput() {
const [temp, setTemp] = useState('');
return (
<label>
Fahrenheit: <input value={temp} onChange={e => setTemp(e.target.value)} />
</label>
);
}
function TemperatureConverter() {
return (
<div>
<CelsiusInput />
<FahrenheitInput />
{/* Typing in Celsius doesn't update Fahrenheit */}
</div>
);
}
Step 2: Create a Generic Input Component
// Generic — doesn't know about temperature scales
function TemperatureInput({ scale, temperature, onTemperatureChange }) {
const scaleNames = { c: 'Celsius', f: 'Fahrenheit' };
return (
<fieldset>
<legend>Enter temperature in {scaleNames[scale]}:</legend>
<input
value={temperature}
onChange={e => onTemperatureChange(e.target.value)}
/>
</fieldset>
);
}
Step 3: Lift State to Parent
// Conversion functions — pure utilities
function toCelsius(fahrenheit) {
return ((fahrenheit - 32) * 5) / 9;
}
function toFahrenheit(celsius) {
return (celsius * 9) / 5 + 32;
}
function tryConvert(temperature, convertFn) {
const input = parseFloat(temperature);
if (Number.isNaN(input)) return '';
const output = convertFn(input);
return Math.round(output * 1000) / 1000;
}
// Parent owns the state and derives both values
function TemperatureConverter() {
const [temperature, setTemperature] = useState('');
const [scale, setScale] = useState('c');
const celsius = scale === 'f' ? tryConvert(temperature, toCelsius) : temperature;
const fahrenheit = scale === 'c' ? tryConvert(temperature, toFahrenheit) : temperature;
const handleCelsiusChange = (temp) => {
setScale('c');
setTemperature(temp);
};
const handleFahrenheitChange = (temp) => {
setScale('f');
setTemperature(temp);
};
return (
<div>
<TemperatureInput
scale="c"
temperature={celsius}
onTemperatureChange={handleCelsiusChange}
/>
<TemperatureInput
scale="f"
temperature={fahrenheit}
onTemperatureChange={handleFahrenheitChange}
/>
{parseFloat(celsius) >= 100 && <p>The water would boil!</p>}
</div>
);
}
Data Flow Diagram
┌─────────────────────────────────────────────────────┐
│ TemperatureConverter │
│ │
│ State: temperature = "100", scale = "c" │
│ │
│ Derived: │
│ celsius = "100" (same, since scale is "c") │
│ fahrenheit = 212 (converted) │
│ │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ Celsius Input │ │ Fahrenheit │ │
│ │ │ │ Input │ │
│ │ value="100" │ │ value="212" │ │
│ │ onChange ──────┼────► │ onChange ──────┼────► │
│ │ sets scale='c'│ sets │ sets scale='f'│ sets │
│ │ sets temp │ parent │ sets temp │parent│
│ └───────────────┘ state └───────────────┘ state │
│ │
└─────────────────────────────────────────────────────┘
User types "100" in Celsius:
1. handleCelsiusChange("100") called
2. State: { temperature: "100", scale: "c" }
3. Re-render:
- celsius = "100" (identity — same value)
- fahrenheit = tryConvert("100", toFahrenheit) = 212
4. Both inputs update
4. The Pattern in Detail
The Anatomy
function Parent() {
// 1. State lives here
const [sharedData, setSharedData] = useState(initialValue);
// 2. Handler functions for children to update state
const handleUpdate = (newValue) => {
setSharedData(newValue);
};
// 3. Derived values (optional)
const derivedA = computeA(sharedData);
const derivedB = computeB(sharedData);
return (
<>
{/* 4. Pass state + setters as props */}
<ChildA data={derivedA} onUpdate={handleUpdate} />
<ChildB data={derivedB} />
</>
);
}
function ChildA({ data, onUpdate }) {
// 5. Child reads data from props, writes via callback
return <input value={data} onChange={e => onUpdate(e.target.value)} />;
}
function ChildB({ data }) {
// 6. Child reads data from props (read-only consumer)
return <div>Value: {data}</div>;
}
Key Principles
| Principle | Explanation |
|---|---|
| Single source of truth | One component owns the state, others receive via props |
| Data flows down | Parent → children via props |
| Events flow up | Children → parent via callback props |
| Derived state | Compute values from source of truth rather than duplicating state |
The Flow
User Action → Child calls callback prop → Parent updates state →
React re-renders parent → New props flow to ALL children → UI updates
5. Case Study: Search + Results
A common pattern: one component handles input, another displays results.
function SearchPage() {
const [query, setQuery] = useState('');
const [filters, setFilters] = useState({ category: 'all', priceRange: 'any' });
// Derived: combine query + filters for API call
const searchParams = useMemo(
() => ({ q: query, ...filters }),
[query, filters]
);
return (
<div className="search-page">
<SearchBar query={query} onQueryChange={setQuery} />
<div className="search-content">
<FilterSidebar filters={filters} onFiltersChange={setFilters} />
<SearchResults params={searchParams} />
</div>
</div>
);
}
// SearchBar — controlled input, no own state
function SearchBar({ query, onQueryChange }) {
return (
<div className="search-bar">
<input
type="search"
placeholder="Search products..."
value={query}
onChange={e => onQueryChange(e.target.value)}
/>
{query && (
<button onClick={() => onQueryChange('')} className="clear-btn">×</button>
)}
</div>
);
}
// FilterSidebar — receives and updates filters
function FilterSidebar({ filters, onFiltersChange }) {
const updateFilter = (key, value) => {
onFiltersChange(prev => ({ ...prev, [key]: value }));
};
return (
<aside className="filters">
<h3>Filters</h3>
<div>
<label>Category</label>
<select
value={filters.category}
onChange={e => updateFilter('category', e.target.value)}
>
<option value="all">All</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
</div>
<div>
<label>Price Range</label>
<select
value={filters.priceRange}
onChange={e => updateFilter('priceRange', e.target.value)}
>
<option value="any">Any</option>
<option value="0-50">$0 - $50</option>
<option value="50-100">$50 - $100</option>
<option value="100+">$100+</option>
</select>
</div>
</aside>
);
}
// SearchResults — uses the combined params to fetch
function SearchResults({ params }) {
const { results, loading, error } = useSearch(params);
if (loading) return <ResultsSkeleton />;
if (error) return <ErrorMessage error={error} />;
if (results.length === 0) return <EmptyState message="No results found" />;
return (
<div className="results-grid">
{results.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
Data Flow
┌──────────────────────────────────────────────┐
│ SearchPage │
│ state: query, filters │
│ derived: searchParams │
│ │
│ ┌──────────────┐ │
│ │ SearchBar │ query ◄── state │
│ │ │ onQueryChange ──► setState │
│ └──────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ FilterSidebar│ │ SearchResults │ │
│ │ │ │ │ │
│ │ filters ◄────│ │ params ◄── derived│ │
│ │ onChange ──► │ │ (triggers fetch) │ │
│ └──────────────┘ └──────────────────┘ │
└──────────────────────────────────────────────┘
6. Case Study: Multi-Panel Dashboard
Three panels that share a selected item.
function Dashboard() {
const [items, setItems] = useState([]);
const [selectedId, setSelectedId] = useState(null);
useEffect(() => {
fetch('/api/items').then(r => r.json()).then(setItems);
}, []);
const selectedItem = items.find(i => i.id === selectedId);
const handleDelete = async (id) => {
await fetch(`/api/items/${id}`, { method: 'DELETE' });
setItems(prev => prev.filter(i => i.id !== id));
if (selectedId === id) setSelectedId(null);
};
return (
<div className="dashboard-layout">
<ItemList
items={items}
selectedId={selectedId}
onSelect={setSelectedId}
/>
<ItemDetail
item={selectedItem}
onDelete={handleDelete}
/>
<ItemTimeline
itemId={selectedId}
/>
</div>
);
}
// Left panel — shows the list with selection highlight
function ItemList({ items, selectedId, onSelect }) {
return (
<nav className="item-list">
{items.map(item => (
<button
key={item.id}
className={item.id === selectedId ? 'selected' : ''}
onClick={() => onSelect(item.id)}
>
{item.name}
</button>
))}
</nav>
);
}
// Center panel — shows detail of selected item
function ItemDetail({ item, onDelete }) {
if (!item) return <EmptyState message="Select an item" />;
return (
<article className="item-detail">
<h2>{item.name}</h2>
<p>{item.description}</p>
<button onClick={() => onDelete(item.id)} className="btn-danger">
Delete
</button>
</article>
);
}
// Right panel — shows activity timeline for selected item
function ItemTimeline({ itemId }) {
const { events, loading } = useItemEvents(itemId);
if (!itemId) return <EmptyState message="Select an item to see activity" />;
if (loading) return <TimelineSkeleton />;
return (
<div className="timeline">
{events.map(event => (
<TimelineEvent key={event.id} event={event} />
))}
</div>
);
}
Why selectedId must be lifted: All three panels need to react to the same selection. If selectedId lived in ItemList, the other panels couldn't know what's selected.
7. Case Study: Form with Live Preview
function BlogPostEditor() {
const [formData, setFormData] = useState({
title: '',
content: '',
tags: [],
coverImage: '',
});
const updateField = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
return (
<div className="editor-layout">
<EditorForm formData={formData} onFieldChange={updateField} />
<LivePreview formData={formData} />
</div>
);
}
function EditorForm({ formData, onFieldChange }) {
return (
<form className="editor-form">
<input
placeholder="Post title"
value={formData.title}
onChange={e => onFieldChange('title', e.target.value)}
/>
<textarea
placeholder="Write your content in Markdown..."
value={formData.content}
onChange={e => onFieldChange('content', e.target.value)}
rows={20}
/>
<TagInput
tags={formData.tags}
onChange={tags => onFieldChange('tags', tags)}
/>
<input
placeholder="Cover image URL"
value={formData.coverImage}
onChange={e => onFieldChange('coverImage', e.target.value)}
/>
</form>
);
}
function LivePreview({ formData }) {
return (
<article className="preview">
{formData.coverImage && (
<img src={formData.coverImage} alt="" className="cover" />
)}
<h1>{formData.title || 'Untitled Post'}</h1>
<div className="tags">
{formData.tags.map(tag => (
<span key={tag} className="tag">#{tag}</span>
))}
</div>
<div className="content">
{renderMarkdown(formData.content) || <p className="placeholder">Start writing...</p>}
</div>
</article>
);
}
The form and preview need the same formData state. Lifting it to BlogPostEditor makes both panels stay in sync.
8. Identifying Where to Lift
The Algorithm
1. Identify which components need the shared state
2. Find their nearest common ancestor in the component tree
3. That ancestor (or sometimes one above it) should own the state
Visual Guide
App
/ | \
A B C
/ \ |
D E F
/ \
G H
If G and F need to share state:
- G is under A → E → B path... wait, let me redo:
App ← Common ancestor of ALL
/ | \
A B C ← C is ancestor of F
/ \ |
D E F
/ \
G H
G needs state. F needs state.
G's path: App → A → E → G
F's path: App → C → F
Common ancestor: App
So the state goes to App (or you restructure the tree).
Rules for Finding the Right Level
| Rule | Explanation |
|---|---|
| As low as possible | Don't lift higher than necessary |
| As high as needed | Must be above ALL consumers |
| Prefer restructuring | Sometimes rearranging the tree is better than lifting to the top |
| Consider context | If lifting creates deep prop drilling, use Context instead |
Example: Finding the Right Level
// Component tree:
// App
// Layout
// Header
// SearchBar ← needs "query"
// Main
// ProductList ← needs "query"
// Option 1: Lift to Layout (nearest common ancestor) ✅
function Layout() {
const [query, setQuery] = useState('');
return (
<>
<Header query={query} onQueryChange={setQuery} />
<Main query={query} />
</>
);
}
// Option 2: Lift to App (too high — unnecessary) ❌
// App doesn't need to know about search queries
// Option 3: Context (if there's deep nesting) ✅ (for complex cases)
9. Lifting State with Callbacks
Children update parent state through callback props. Here are common patterns:
Pattern 1: Simple Setter
// Parent passes setState directly
function Parent() {
const [value, setValue] = useState('');
return <Child value={value} onChange={setValue} />;
}
function Child({ value, onChange }) {
return <input value={value} onChange={e => onChange(e.target.value)} />;
}
Pattern 2: Action Callbacks
// Parent provides specific action callbacks
function TodoApp() {
const [todos, setTodos] = useState([]);
const addTodo = (text) => {
setTodos(prev => [...prev, { id: Date.now(), text, done: false }]);
};
const toggleTodo = (id) => {
setTodos(prev => prev.map(t =>
t.id === id ? { ...t, done: !t.done } : t
));
};
const deleteTodo = (id) => {
setTodos(prev => prev.filter(t => t.id !== id));
};
return (
<>
<AddTodoForm onAdd={addTodo} />
<TodoList todos={todos} onToggle={toggleTodo} onDelete={deleteTodo} />
</>
);
}
Pattern 3: Updater Function
// Parent passes an updater that accepts a transformation function
function ShoppingCart() {
const [items, setItems] = useState([]);
// Instead of multiple callbacks, one flexible updater
const updateItem = (id, updates) => {
setItems(prev => prev.map(item =>
item.id === id ? { ...item, ...updates } : item
));
};
return (
<CartItemList items={items} onUpdateItem={updateItem} />
);
}
function CartItemList({ items, onUpdateItem }) {
return items.map(item => (
<CartItem
key={item.id}
item={item}
onQuantityChange={(qty) => onUpdateItem(item.id, { quantity: qty })}
onNoteChange={(note) => onUpdateItem(item.id, { note })}
/>
));
}
10. Performance Considerations
Lifting state means the parent re-renders on every state change, which re-renders ALL children — even those that don't use the changed state.
The Problem
function Page() {
const [searchQuery, setSearchQuery] = useState('');
return (
<>
<SearchBar query={searchQuery} onQueryChange={setSearchQuery} />
<ExpensiveChart /> {/* Re-renders on every keystroke! */}
<SearchResults query={searchQuery} />
</>
);
}
Every time the user types in the search bar, ExpensiveChart re-renders even though it doesn't use searchQuery.
Solution 1: React.memo
// Wrap expensive components that don't depend on the changed state
const ExpensiveChart = React.memo(function ExpensiveChart() {
// Only re-renders if its props change
return <canvas>...</canvas>;
});
Solution 2: Move State Closer
// If SearchBar and SearchResults are next to each other, wrap them
function SearchSection() {
const [query, setQuery] = useState('');
return (
<>
<SearchBar query={query} onQueryChange={setQuery} />
<SearchResults query={query} />
</>
);
}
function Page() {
return (
<>
<SearchSection /> {/* State scoped here */}
<ExpensiveChart /> {/* Never re-renders from search */}
</>
);
}
Solution 3: Composition Pattern (children)
// Page accepts children — SearchSection's state doesn't trigger Page re-render
function Page({ children }) {
return (
<div className="page">
{children}
<ExpensiveChart />
</div>
);
}
function App() {
const [query, setQuery] = useState('');
return (
<Page>
<SearchBar query={query} onQueryChange={setQuery} />
<SearchResults query={query} />
</Page>
);
}
Performance Decision Table
| Scenario | Solution |
|---|---|
| Sibling doesn't use lifted state | React.memo the sibling |
| Two components are closely related | Wrap in a sub-parent |
| Deep tree with state at top | Consider Context or state library |
| Frequent updates (typing, dragging) | Keep state as low as possible |
| Expensive child computations | useMemo inside the child |
11. When Lifting Goes Too High
If you keep lifting state, it eventually reaches the root component — making everything pass through the entire tree.
Symptom: The "Pass-Through Prop" Cascade
// ❌ State lifted too high — now it's passed through 4 layers
function App() {
const [user, setUser] = useState(null);
return <Layout user={user} onLogin={setUser} />;
}
function Layout({ user, onLogin }) {
return (
<div>
<Header user={user} onLogin={onLogin} />
<Main user={user} />
</div>
);
}
function Header({ user, onLogin }) {
return (
<header>
<Nav user={user} onLogin={onLogin} />
</header>
);
}
function Nav({ user, onLogin }) {
return user ? <span>{user.name}</span> : <button onClick={onLogin}>Login</button>;
}
Layout and Header don't use user or onLogin — they just pass them through. This is prop drilling, covered in the next sub-topic.
The Threshold
| Levels Deep | Recommendation |
|---|---|
| 1 level | Always fine — lift freely |
| 2 levels | Usually fine |
| 3 levels | Consider Context or restructuring |
| 4+ levels | Use Context, Zustand, or Redux |
12. Lifting State vs Other Solutions
| Approach | When to Use | Complexity |
|---|---|---|
| Lift state up | 2-3 siblings need shared data, 1-2 levels deep | Low |
| Context API | Many components across different levels need the same data | Medium |
| Zustand/Redux | Complex global state with many updaters | Medium-High |
| URL state | State that should survive page refresh (search params, filters) | Low |
| Server state (TanStack Query) | Data from APIs that multiple components consume | Medium |
Decision Guide
Do 2+ components need the same state?
└─► YES
│
How many levels apart?
├─► 1-2 levels ───► LIFT STATE UP
├─► 3+ levels ────► Context API
└─► Across routes ─► URL state or global store
└─► NO ──► Keep state local. Don't lift.
13. Common Mistakes
Mistake 1: Duplicating State Instead of Lifting
// ❌ Both components have their own copy of "selected"
function TabBar({ onSelect }) {
const [selected, setSelected] = useState(0);
return tabs.map((tab, i) => (
<button
key={i}
className={i === selected ? 'active' : ''}
onClick={() => { setSelected(i); onSelect(i); }}
>
{tab}
</button>
));
}
function TabContent({ selectedIndex }) {
// selectedIndex might get out of sync with TabBar's selected!
return <div>{contents[selectedIndex]}</div>;
}
// ✅ Single source of truth — parent owns selected
function TabContainer() {
const [selected, setSelected] = useState(0);
return (
<>
<TabBar selected={selected} onSelect={setSelected} />
<TabContent selectedIndex={selected} />
</>
);
}
Mistake 2: Lifting State That Doesn't Need Lifting
// ❌ isHovered is pure UI state — no sibling needs it
function Parent() {
const [isHovered, setIsHovered] = useState(false);
return <Card isHovered={isHovered} onHover={setIsHovered} />;
}
// ✅ Keep hover state local
function Card() {
const [isHovered, setIsHovered] = useState(false);
return (
<div
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={isHovered ? 'shadow-lg' : 'shadow'}
>
...
</div>
);
}
Mistake 3: Lifting Too High
Already covered in section 11 — don't lift to the root if only nearby components need it.
Mistake 4: Not Using Derived State
// ❌ Maintaining two pieces of state that should be derived
function Parent() {
const [items, setItems] = useState([]);
const [itemCount, setItemCount] = useState(0); // ← This is derived!
const addItem = (item) => {
setItems(prev => [...prev, item]);
setItemCount(prev => prev + 1); // ← Keeping two sources of truth in sync
};
return (
<>
<ItemList items={items} />
<ItemCount count={itemCount} />
</>
);
}
// ✅ Derive instead of duplicate
function Parent() {
const [items, setItems] = useState([]);
return (
<>
<ItemList items={items} />
<ItemCount count={items.length} /> {/* Derived! */}
</>
);
}
14. Key Takeaways
-
Lifting state up means moving shared state to the nearest common ancestor of the components that need it.
-
Three steps: Remove state from child → add to parent → pass down as props + callbacks.
-
Single source of truth: Only one component should own each piece of state. Others receive via props.
-
Data flows down, events flow up: Props carry data to children; callback props carry events back to the parent.
-
Derive instead of duplicate: Calculate values from state rather than maintaining redundant state variables.
-
Keep state as low as possible: Only lift when siblings need to share. Don't lift UI-only state (hover, isOpen) unless another component depends on it.
-
Performance: Lifting state causes parent re-renders. Use
React.memo, composition, or restructuring to avoid unnecessary renders. -
Know the limits: If lifting creates 3+ levels of pass-through props, consider Context or a state management library.
Explain-It Challenge
-
Explain to a non-developer: Using the analogy of a family bulletin board (vs individual notebooks), explain why shared state needs to live in a common place rather than in individual components.
-
Redesign this: You have
ColorPicker,Preview, andCSSOutputcomponents that all need the same selected color. The current tree is:App > ToolPanel > ColorPickerandApp > OutputPanel > PreviewandApp > OutputPanel > CSSOutput. Where should the color state live? Draw the data flow. -
Refactor this: Given a component where
Headerhas asearchQuerystate andMain > ProductGridneeds that query — refactor to lift the state correctly and show the before/after code.
Navigation: ← Smart vs Dumb Components · Next → Prop Drilling Problem