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

  1. The Problem: Siblings Can't Talk Directly
  2. What "Lifting State Up" Means
  3. Step-by-Step: Temperature Converter
  4. The Pattern in Detail
  5. Case Study: Search + Results
  6. Case Study: Multi-Panel Dashboard
  7. Case Study: Form with Live Preview
  8. Identifying Where to Lift
  9. Lifting State with Callbacks
  10. Performance Considerations
  11. When Lifting Goes Too High
  12. Lifting State vs Other Solutions
  13. Common Mistakes
  14. 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

  1. Remove state from the child component
  2. Add state to the nearest common parent
  3. 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

PrincipleExplanation
Single source of truthOne component owns the state, others receive via props
Data flows downParent → children via props
Events flow upChildren → parent via callback props
Derived stateCompute 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

RuleExplanation
As low as possibleDon't lift higher than necessary
As high as neededMust be above ALL consumers
Prefer restructuringSometimes rearranging the tree is better than lifting to the top
Consider contextIf 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

ScenarioSolution
Sibling doesn't use lifted stateReact.memo the sibling
Two components are closely relatedWrap in a sub-parent
Deep tree with state at topConsider Context or state library
Frequent updates (typing, dragging)Keep state as low as possible
Expensive child computationsuseMemo 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 DeepRecommendation
1 levelAlways fine — lift freely
2 levelsUsually fine
3 levelsConsider Context or restructuring
4+ levelsUse Context, Zustand, or Redux

12. Lifting State vs Other Solutions

ApproachWhen to UseComplexity
Lift state up2-3 siblings need shared data, 1-2 levels deepLow
Context APIMany components across different levels need the same dataMedium
Zustand/ReduxComplex global state with many updatersMedium-High
URL stateState that should survive page refresh (search params, filters)Low
Server state (TanStack Query)Data from APIs that multiple components consumeMedium

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

  1. Lifting state up means moving shared state to the nearest common ancestor of the components that need it.

  2. Three steps: Remove state from child → add to parent → pass down as props + callbacks.

  3. Single source of truth: Only one component should own each piece of state. Others receive via props.

  4. Data flows down, events flow up: Props carry data to children; callback props carry events back to the parent.

  5. Derive instead of duplicate: Calculate values from state rather than maintaining redundant state variables.

  6. 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.

  7. Performance: Lifting state causes parent re-renders. Use React.memo, composition, or restructuring to avoid unnecessary renders.

  8. Know the limits: If lifting creates 3+ levels of pass-through props, consider Context or a state management library.


Explain-It Challenge

  1. 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.

  2. Redesign this: You have ColorPicker, Preview, and CSSOutput components that all need the same selected color. The current tree is: App > ToolPanel > ColorPicker and App > OutputPanel > Preview and App > OutputPanel > CSSOutput. Where should the color state live? Draw the data flow.

  3. Refactor this: Given a component where Header has a searchQuery state and Main > ProductGrid needs that query — refactor to lift the state correctly and show the before/after code.


Navigation: ← Smart vs Dumb Components · Next → Prop Drilling Problem