Lifting State Up & Prop Drilling
Lifting State Up & Prop Drilling
LinkedIn Hook
You have two sibling components — a temperature input in Celsius and one in Fahrenheit.
When the user types in one, the other should update automatically.
But siblings can't talk to each other in React. There's no "horizontal" data flow. So how do you sync them?
You lift the state up. You move the shared data to the nearest common parent and pass it down as props.
Sounds simple — until your app has 15 levels of components and you're threading the same
userobject through every single one. That's the prop drilling problem.In this lesson, I break down lifting state up — why it exists, when it's the right call, how to do it step by step, and the exact moment prop drilling stops being "fine" and starts being a maintenance disaster.
If an interviewer asks "how do siblings share state?" — this is your answer.
Read the full lesson -> [link]
#React #JavaScript #WebDevelopment #InterviewPrep #Frontend #CodingInterview #LiftingStateUp #PropDrilling #100DaysOfCode
What You'll Learn
- Why sibling components cannot share state directly in React
- How to lift state up to a common parent to synchronize siblings
- The step-by-step pattern for lifting state up
- What prop drilling is and why it becomes painful at scale
- When prop drilling is perfectly acceptable vs when you need a different approach
- How component composition can reduce prop drilling without adding libraries
1. The Problem — Siblings Can't Talk
The Analogy
Imagine two coworkers sitting in separate offices. They both need to work with the same document. They can't just shout across the hall — there's no direct line between them.
Instead, they share the document through their manager. The manager holds the single source of truth, and each coworker gets a copy. When one coworker makes a change, they report it to the manager, who updates the master document and sends the updated version to both coworkers.
In React, the "coworkers" are sibling components, the "manager" is their common parent, and the "document" is the shared state. This is lifting state up.
Why React Works This Way
React enforces one-way data flow — data flows downward from parent to child through props. There is no built-in mechanism for a child to send data sideways to a sibling. This is intentional:
- It makes data flow predictable and easy to trace
- It prevents the tangled "spaghetti" communication that happens in frameworks with two-way binding
- It creates a single source of truth for every piece of data
The tradeoff is that when siblings need to share data, the state must live in a component that is an ancestor of both — typically their nearest common parent.
2. Lifting State Up — Step by Step
The pattern always follows the same three steps:
- Identify the shared state — what data do both siblings need?
- Move that state to the nearest common parent
- Pass the state down as props, and pass a callback function so children can request changes
Code Example 1: Temperature Converter
Two inputs that stay in sync — Celsius and Fahrenheit.
import { useState } from "react";
// Step 2: Parent owns the shared state
function TemperatureCalculator() {
// Single source of truth — temperature stored in Celsius
const [celsius, setCelsius] = useState("");
// Conversion functions
const toFahrenheit = (c) => (c === "" ? "" : (c * 9) / 5 + 32);
const toCelsius = (f) => (f === "" ? "" : ((f - 32) * 5) / 9);
// Step 3: Handle changes from either child
function handleCelsiusChange(value) {
setCelsius(value);
}
function handleFahrenheitChange(value) {
// Convert Fahrenheit input back to Celsius (our source of truth)
setCelsius(toCelsius(value));
}
return (
<div>
<h2>Temperature Converter</h2>
{/* Step 3: Pass state down and callback for changes */}
<TemperatureInput
label="Celsius"
value={celsius}
onChange={handleCelsiusChange}
/>
<TemperatureInput
label="Fahrenheit"
value={toFahrenheit(celsius)}
onChange={handleFahrenheitChange}
/>
</div>
);
}
// Step 1: Child is now a "controlled" component — no local state
function TemperatureInput({ label, value, onChange }) {
return (
<label>
{label}:{" "}
<input
type="number"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</label>
);
}
// How it works:
// 1. User types "100" in the Celsius input
// 2. TemperatureInput calls onChange("100")
// 3. Parent sets celsius to "100"
// 4. Parent re-renders, passing celsius="100" and fahrenheit="212" to both inputs
// 5. Both inputs update simultaneously
Key insight: The TemperatureInput component no longer owns any state. It receives its value from the parent and reports changes back through a callback. This is what makes it "controlled" — the parent controls it completely.
3. A Realistic Example — Sibling Communication
Code Example 2: Product Filter and Product List
A common real-world scenario — a search filter and a product list that need to share the filter value.
import { useState } from "react";
// Parent owns the shared state
function ProductPage() {
const [searchTerm, setSearchTerm] = useState("");
const [inStockOnly, setInStockOnly] = useState(false);
// Product data (in real app, this comes from an API)
const products = [
{ name: "Football", price: 49.99, inStock: true },
{ name: "Basketball", price: 29.99, inStock: true },
{ name: "Tennis Racket", price: 99.99, inStock: false },
{ name: "Baseball Bat", price: 39.99, inStock: true },
];
return (
<div>
{/* Sibling 1: controls the filter */}
<SearchBar
searchTerm={searchTerm}
inStockOnly={inStockOnly}
onSearchChange={setSearchTerm}
onStockChange={setInStockOnly}
/>
{/* Sibling 2: displays filtered results */}
<ProductList
products={products}
searchTerm={searchTerm}
inStockOnly={inStockOnly}
/>
</div>
);
}
// SearchBar — reports user input to parent
function SearchBar({ searchTerm, inStockOnly, onSearchChange, onStockChange }) {
return (
<div>
<input
type="text"
placeholder="Search products..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
/>
<label>
<input
type="checkbox"
checked={inStockOnly}
onChange={(e) => onStockChange(e.target.checked)}
/>
Only show in-stock products
</label>
</div>
);
}
// ProductList — filters and displays based on parent's state
function ProductList({ products, searchTerm, inStockOnly }) {
// Filtering is a derived value — computed from props, not stored in state
const filtered = products.filter((product) => {
const matchesSearch = product.name
.toLowerCase()
.includes(searchTerm.toLowerCase());
const matchesStock = inStockOnly ? product.inStock : true;
return matchesSearch && matchesStock;
});
return (
<ul>
{filtered.map((product) => (
<li key={product.name}>
{product.name} — ${product.price}
{!product.inStock && <span style={{ color: "red" }}> (Out of stock)</span>}
</li>
))}
</ul>
);
}
// Output (with searchTerm="" and inStockOnly=false):
// - Football — $49.99
// - Basketball — $29.99
// - Tennis Racket — $99.99 (Out of stock)
// - Baseball Bat — $39.99
//
// Output (with searchTerm="ba" and inStockOnly=true):
// - Basketball — $29.99
// - Baseball Bat — $39.99
Notice the pattern: SearchBar doesn't know ProductList exists, and ProductList doesn't know SearchBar exists. They communicate entirely through the parent. This keeps both components reusable and decoupled.
4. The Prop Drilling Problem
Lifting state up works perfectly when the parent is 1-2 levels above the children that need the data. But what happens when the component that needs the data is buried 5, 8, or 12 levels deep?
Code Example 3: Prop Drilling in Action
// The "user" data originates in App but is needed by Avatar (4 levels deep)
function App() {
const [user, setUser] = useState({ name: "Rakibul", avatar: "/img/rak.png" });
return <Layout user={user} />;
}
// Layout doesn't use "user" — just forwards it
function Layout({ user }) {
return (
<div className="layout">
<Sidebar user={user} />
<main>Content here</main>
</div>
);
}
// Sidebar doesn't use "user" — just forwards it
function Sidebar({ user }) {
return (
<nav>
<NavMenu user={user} />
</nav>
);
}
// NavMenu doesn't use "user" — just forwards it
function NavMenu({ user }) {
return (
<ul>
<li>Home</li>
<li>Settings</li>
<li><Avatar user={user} /></li>
</ul>
);
}
// Avatar FINALLY uses "user"
function Avatar({ user }) {
return <img src={user.avatar} alt={user.name} />;
}
// The "user" prop passes through: App -> Layout -> Sidebar -> NavMenu -> Avatar
// Three intermediate components (Layout, Sidebar, NavMenu) receive and forward
// a prop they never use. This is prop drilling.
Why this is painful at scale:
- Adding a new prop: If
Avatarnow also needsuser.email, you must updateApp,Layout,Sidebar, ANDNavMenu— even though onlyAppandAvatarcare about it. - Refactoring risk: Renaming the
userprop means changing it in every intermediate component. - Tight coupling:
Layout,Sidebar, andNavMenuare now coupled to theusershape even though they don't use it. - Readability: A new developer reading
Sidebarsees auserprop and assumes the sidebar uses it somehow. It doesn't — it just passes it along.
5. When Prop Drilling Is Fine vs When It's Not
Prop Drilling Is Fine When:
- The depth is 2-3 levels — this is normal React. The data flow is explicit and easy to trace.
- The data is genuinely used at each level — if every component in the chain actually uses the prop, that's not drilling, that's just data flow.
- The component tree is stable — if the structure rarely changes, the maintenance cost of drilling is low.
- You're building a small app — the overhead of Context or a state library isn't justified.
Prop Drilling Is NOT Fine When:
- 4+ levels of components just forwarding props they don't use
- Multiple unrelated props being drilled through the same path
- The data is needed in many distant parts of the tree (like theme, auth, locale)
- Adding one prop to a deep child requires editing 5+ files
- You find yourself writing
{...props}spread just to forward everything blindly
Code Example 4: Composition as a Prop Drilling Fix
Before reaching for Context or Redux, you can often restructure your component tree to avoid drilling entirely. This is component composition.
// BEFORE: Prop drilling through Layout and Sidebar
function App() {
const [user, setUser] = useState({ name: "Rakibul", avatar: "/img/rak.png" });
return (
<Layout user={user}>
{/* user drills through Layout -> Sidebar -> NavMenu -> Avatar */}
</Layout>
);
}
// AFTER: Using composition — pass the rendered component, not the data
function App() {
const [user, setUser] = useState({ name: "Rakibul", avatar: "/img/rak.png" });
// App renders Avatar directly — no drilling needed
const avatar = <Avatar user={user} />;
return (
<Layout>
<Sidebar>
<NavMenu avatar={avatar} />
</Sidebar>
</Layout>
);
}
// Layout, Sidebar no longer need to know about "user" at all
function Layout({ children }) {
return <div className="layout">{children}</div>;
}
function Sidebar({ children }) {
return <nav>{children}</nav>;
}
// NavMenu receives the already-rendered Avatar, not raw user data
function NavMenu({ avatar }) {
return (
<ul>
<li>Home</li>
<li>Settings</li>
<li>{avatar}</li>
</ul>
);
}
function Avatar({ user }) {
return <img src={user.avatar} alt={user.name} />;
}
// Now "user" only exists in App and Avatar — zero drilling.
// Layout and Sidebar are generic containers using children.
The composition approach works because you're moving the "who renders what" decision up to the top. Instead of passing data down so a deep child can render itself, the top-level component renders the child directly and passes it as JSX.
Common Mistakes
1. Duplicating State Instead of Lifting It
// WRONG — two separate states that will get out of sync
function CelsiusInput() {
const [temp, setTemp] = useState("");
return <input value={temp} onChange={(e) => setTemp(e.target.value)} />;
}
function FahrenheitInput() {
const [temp, setTemp] = useState("");
return <input value={temp} onChange={(e) => setTemp(e.target.value)} />;
}
// Each input has its own state — they never sync.
// FIX: Lift the state to the parent (as shown in Example 1).
If two components need the same data, there should be a single source of truth in their common parent. Duplicating state across siblings always leads to sync bugs.
2. Lifting State Too High
// WRONG — lifting user state all the way to the root when only one subtree needs it
function App() {
const [user, setUser] = useState(null);
const [products, setProducts] = useState([]);
const [cart, setCart] = useState([]);
const [theme, setTheme] = useState("dark");
const [locale, setLocale] = useState("en");
// App becomes a "god component" holding ALL state
return <Layout user={user} products={products} cart={cart} theme={theme} locale={locale} />;
}
// FIX: Lift state only to the NEAREST common parent of the components that need it.
// If only ProfilePage and ProfileHeader need "user", put user state in ProfilePage — not App.
Lifting state higher than necessary causes unrelated components to re-render when that state changes, and it turns the root component into an unmanageable blob.
3. Forgetting the Callback Function
// WRONG — child receives state but has no way to update it
function Parent() {
const [value, setValue] = useState("");
return <Child value={value} />;
// Child can display the value but can't change it!
}
// RIGHT — always pass a callback when the child needs to modify shared state
function Parent() {
const [value, setValue] = useState("");
return <Child value={value} onChange={setValue} />;
}
Lifting state up means the parent owns the state AND the update logic. The child only communicates through the callback.
Interview Questions
Q: How do sibling components share state in React?
Siblings cannot communicate directly. To share state, you lift the state up to their nearest common parent. The parent holds the state and passes it down as props to both siblings. When one sibling needs to update the state, it calls a callback function (also passed as a prop) that the parent provided. The parent updates the state, triggering a re-render that sends the new data to both siblings.
Q: What is "lifting state up"? Walk me through the pattern.
Lifting state up is the process of moving state from a child component to its parent when that state needs to be shared with sibling components. The pattern has three steps: (1) identify the shared data, (2) move the state to the nearest common parent using useState, and (3) pass the state value and an onChange callback down to the children as props. The children become "controlled" — they no longer own the state, they just display it and report changes.
Q: What is prop drilling, and when does it become a problem?
Prop drilling is passing data through multiple levels of intermediate components that don't use the data — they just forward it to their children. It's fine through 2-3 levels because the data flow is explicit and traceable. It becomes a problem at 4+ levels because adding or renaming a prop requires editing every intermediate component, it couples those components to data they don't use, and it makes refactoring risky. Solutions include component composition (restructuring to avoid drilling), React Context, or state management libraries.
Q: What is component composition, and how does it solve prop drilling?
Component composition solves prop drilling by passing already-rendered JSX down through the tree instead of passing raw data. Instead of the deep child rendering itself from data drilled through many levels, the top-level component renders the child directly and passes it as a
childrenprop or a named prop. Intermediate components become generic containers that render{children}without needing to know about the data. This eliminates the coupling between intermediate components and the drilled data.
Q: You have a form with 10 fields spread across 4 sub-components. Where should the form state live?
The form state should live in the nearest common parent of all 4 sub-components — typically the form component itself. Each sub-component receives its relevant field values as props and an onChange callback to report changes. This ensures a single source of truth, makes validation easy (the parent has all field values), and keeps the sub-components reusable. If the nesting gets deep, composition or Context can reduce drilling. For complex forms, a form library like React Hook Form can manage this more elegantly.
Quick Reference — Cheat Sheet
+----------------------------------------------------------------------+
| LIFTING STATE UP & PROP DRILLING — CHEAT SHEET |
+----------------------------------------------------------------------+
| |
| LIFTING STATE UP |
| 1. Identify shared state between siblings |
| 2. Move state to nearest common parent |
| 3. Pass value + onChange callback down as props |
| 4. Children become "controlled" — no local state for shared data |
| |
| THE PATTERN |
| Parent: const [value, setValue] = useState(initial) |
| Parent: <ChildA value={value} onChange={setValue} /> |
| Parent: <ChildB value={value} /> |
| Child: props.onChange(newValue) // reports change to parent |
| |
| PROP DRILLING |
| App -> Layout -> Sidebar -> NavMenu -> Avatar |
| ^ ^ ^ |
| (unused) (unused) (unused) |
| |
| WHEN DRILLING IS FINE WHEN DRILLING IS NOT FINE |
| - 2-3 levels deep - 4+ levels of forwarding |
| - Data used at each level - Many unrelated props drilled |
| - Small, stable tree - Data needed in distant subtrees |
| - Simple app - Editing 5+ files for one prop |
| |
| SOLUTIONS TO DRILLING |
| 1. Component composition → restructure tree, use children |
| 2. React Context (Ch 3) → built-in, best for low-freq data |
| 3. State libraries (Ch 6) → Redux, Zustand for complex apps |
| |
| COMMON TRAPS |
| x Duplicating state in siblings → always single source of truth |
| x Lifting state too high → only to nearest common parent |
| x Forgetting onChange callback → child can't update without it |
| x Using composition too late → try it before reaching for |
| Context or Redux |
| |
+----------------------------------------------------------------------+
Previous: Lesson 2.2 — State — Component Memory -> Next: Lesson 2.4 — Controlled vs Uncontrolled Components ->
This is Lesson 2.3 of the React Interview Prep Course — 10 chapters, 42 lessons.