React State Management Fundamentals
Question 1: What is state in React and how does it differ from props?β
Answerβ
State is a JavaScript object that holds dynamic data that can change over time and affects what gets rendered. It's managed internally by a component and can be modified using setState (class components) or useState (functional components). When state changes, React re-renders the component to reflect the new data.
Props (properties) are read-only data passed from a parent component to a child component. They flow in one direction (top-down) and cannot be modified by the receiving component. Props are the mechanism for parent-child communication in React's component tree.
Key Differences:
- Mutability: State can be changed by the component that owns it; props are immutable from the child's perspective
- Ownership: State is private and controlled by the component; props are controlled by the parent
- Initialization: State is initialized in the component; props are passed from outside
- Updates: State updates trigger re-renders; prop changes from parent also trigger re-renders
- Scope: State is local to the component; props are external inputs
// State - owned and mutable by component
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
// Props - passed from parent, immutable in child
function Greeting({ name }) {
// name = "something"; // β Cannot modify props
return <h1>Hello, {name}</h1>;
}
function App() {
return <Greeting name="Alice" />;
}
Understanding this distinction is fundamental to React's unidirectional data flow and component composition patterns.
π Deep Diveβ
State Storage in Fiber Architecture:
React stores state in fiber nodes, which are JavaScript objects representing component instances in the virtual DOM tree. Each fiber has a memoizedState property that holds the component's state. For functional components using hooks, state is stored as a linked list of hook objects.
// Simplified fiber node structure
{
type: FunctionComponent,
memoizedState: {
// For hooks, this is a linked list
next: hookObject,
memoizedState: actualStateValue,
queue: updateQueue
},
memoizedProps: { /* props object */ },
updateQueue: { /* pending state updates */ }
}
When you call useState, React:
- Creates or retrieves a hook object from the fiber's memoizedState linked list
- Returns the current state value and a dispatch function
- On state updates, queues the update in the hook's update queue
- Schedules a re-render using React's scheduler
- During re-render, processes queued updates to compute new state
Props vs State Mechanics:
Props are stored in fiber.memoizedProps and fiber.pendingProps. When a parent re-renders with new props:
- React creates new fiber nodes (or reuses existing ones)
- Compares old props with new props (shallow equality by default)
- If different, marks the fiber for re-rendering
- Passes new props down the tree during the render phase
State updates follow a different path:
function useState(initialState) {
const hook = getCurrentHook(); // Get current hook from fiber
const dispatch = (action) => {
const update = {
action,
next: null,
eagerState: null
};
// Add update to queue
enqueueUpdate(hook.queue, update);
// Schedule re-render
scheduleUpdateOnFiber(currentFiber);
};
return [hook.memoizedState, dispatch];
}
Unidirectional Data Flow:
React enforces one-way data binding:
- Props flow down: Parent β Child β Grandchild
- Events flow up: Child calls parent's callback function
- State is local: Each component owns its state
// β
GOOD: Unidirectional flow
function TodoApp() {
const [todos, setTodos] = useState([]);
// State flows down as props
// Events (callbacks) flow up
return (
<div>
<TodoList
todos={todos}
onToggle={(id) => setTodos(toggleTodo(todos, id))}
/>
<AddTodo onAdd={(text) => setTodos([...todos, createTodo(text)])} />
</div>
);
}
// β BAD: Trying to modify props (React prevents this)
function TodoItem({ todo }) {
todo.completed = true; // β Won't work, props are read-only
return <li>{todo.text}</li>;
}
// β
GOOD: Use callbacks to request parent updates
function TodoItem({ todo, onToggle }) {
return (
<li onClick={() => onToggle(todo.id)}>
{todo.text}
</li>
);
}
State Batching and Updates:
React batches multiple state updates for performance:
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
// These are batched in React 18+
setCount(count + 1); // count = 0, queues update to 1
setCount(count + 1); // count still 0, queues update to 1 (not 2!)
console.log(count); // Still 0 - updates are async
};
const handleClickCorrect = () => {
// Use functional updates to access latest state
setCount(c => c + 1); // Queues: state => state + 1
setCount(c => c + 1); // Queues: state => state + 1
// Result: count increases by 2
};
return <button onClick={handleClickCorrect}>{count}</button>;
}
Memory Management:
State persists for the component's lifetime:
- Component mounts β State initialized
- Component updates β State retained in fiber
- Component unmounts β State garbage collected
- Component remounts β State re-initialized (fresh start)
function FormInput() {
const [value, setValue] = useState('');
useEffect(() => {
console.log('Mount - state initialized');
return () => console.log('Unmount - state will be GC\'d');
}, []);
// State persists across re-renders
// But is lost on unmount
return <input value={value} onChange={e => setValue(e.target.value)} />;
}
π Real-World Scenarioβ
Scenario: E-commerce Cart State Synchronization Bug
Context: A mid-sized e-commerce platform (50K daily users) had a critical bug where cart quantities would become desynchronized between the cart icon badge and the cart page. Users reported seeing "3 items" in the header but only 2 items on the cart page. The bug occurred in ~5% of transactions, causing customer support tickets to spike by 200%.
Root Cause:
Two components were maintaining separate state for the same data:
// β BAD: Duplicate state in multiple components
function Header() {
const [cartCount, setCartCount] = useState(0);
// Trying to sync with localStorage
useEffect(() => {
const cart = JSON.parse(localStorage.getItem('cart') || '[]');
setCartCount(cart.length);
}, []); // Only runs once!
return <div>Cart: {cartCount}</div>;
}
function CartPage() {
const [items, setItems] = useState([]);
useEffect(() => {
const cart = JSON.parse(localStorage.getItem('cart') || '[]');
setItems(cart);
}, []);
const removeItem = (id) => {
const newItems = items.filter(item => item.id !== id);
setItems(newItems);
localStorage.setItem('cart', JSON.stringify(newItems));
// Header doesn't know about this update!
};
return <div>{items.map(item => <CartItem key={item.id} {...item} />)}</div>;
}
Investigation Metrics:
- Bug reproduction rate: 5% of sessions (increased with faster user interactions)
- Time to inconsistency: 30-120 seconds after cart modification
- Components with cart state: 4 (Header, CartPage, Checkout, ProductList)
- Average prop drilling depth: 5 levels
- Re-render count when adding item: 18 components
Debugging Steps:
-
React DevTools Profiler Analysis:
- Recorded user session adding/removing items
- Found 4 different components reading from localStorage
- Discovered Header wasn't re-rendering on cart changes
-
State Flow Tracing:
// Added debugginguseEffect(() => {console.log('Header cart count:', cartCount);}, [cartCount]);// Found: count never updated after initial mount -
Event Timeline:
- User adds item β CartPage updates localStorage
- CartPage re-renders with new items
- Header doesn't know about the change (no re-render trigger)
- User navigates β sees stale count in header
Solution: Lift State Up
// β
GOOD: Single source of truth
function App() {
const [cartItems, setCartItems] = useState(() => {
return JSON.parse(localStorage.getItem('cart') || '[]');
});
// Sync to localStorage on every change
useEffect(() => {
localStorage.setItem('cart', JSON.stringify(cartItems));
}, [cartItems]);
const addToCart = (product) => {
setCartItems(items => [...items, product]);
};
const removeFromCart = (id) => {
setCartItems(items => items.filter(item => item.id !== id));
};
const updateQuantity = (id, quantity) => {
setCartItems(items =>
items.map(item =>
item.id === id ? { ...item, quantity } : item
)
);
};
return (
<CartContext.Provider value={{
items: cartItems,
addToCart,
removeFromCart,
updateQuantity
}}>
<Header cartCount={cartItems.length} />
<Routes>
<Route path="/cart" element={<CartPage />} />
<Route path="/product/:id" element={<ProductPage />} />
</Routes>
</CartContext.Provider>
);
}
function Header({ cartCount }) {
return <div>Cart: {cartCount}</div>; // Always correct
}
function CartPage() {
const { items, removeFromCart, updateQuantity } = useContext(CartContext);
return (
<div>
{items.map(item => (
<CartItem
key={item.id}
{...item}
onRemove={() => removeFromCart(item.id)}
onUpdateQuantity={(qty) => updateQuantity(item.id, qty)}
/>
))}
</div>
);
}
Results After Fix:
- Bug occurrence: 0% (eliminated completely)
- Cart synchronization issues: 0 customer support tickets
- Re-render count when adding item: 6 components (down from 18)
- Code maintainability: 40% reduction in cart-related code
- Performance: 15% faster cart operations (fewer localStorage reads)
Additional Improvements:
// Added optimistic updates for better UX
const addToCart = async (product) => {
// Optimistically update UI
setCartItems(items => [...items, product]);
try {
// Sync to backend
await api.addToCart(product.id);
} catch (error) {
// Rollback on error
setCartItems(items => items.filter(item => item.id !== product.id));
showError('Failed to add item');
}
};
// Added useMemo for derived state
const cartTotal = useMemo(() =>
cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0),
[cartItems]
);
Lessons Learned:
- Never duplicate state across components - lift it up
- localStorage is not a state management solution (no reactivity)
- Single source of truth prevents synchronization bugs
- Context API is better than prop drilling for global state
- Always sync external storage (localStorage, IndexedDB) reactively
<details> <summary><strong>βοΈ Trade-offs: Local State vs Lifted State</strong></summary>
1. Local State vs Lifted State
Local State (Keep State Close to Where It's Used):
// β
GOOD: Local state for local UI
function SearchInput() {
const [query, setQuery] = useState('');
const [isFocused, setIsFocused] = useState(false);
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
className={isFocused ? 'focused' : ''}
/>
);
}
Pros:
- Simple and straightforward
- Easy to reason about
- Better performance (fewer re-renders)
- Component is self-contained and reusable
- No prop drilling
Cons:
- Can't share state with siblings
- Hard to persist or sync
- Difficult to test in isolation
- Limited control from parent
Lifted State (Move State to Common Ancestor):
// β
GOOD: Lifted state for shared data
function Dashboard() {
const [filters, setFilters] = useState({ category: 'all', sort: 'date' });
return (
<>
<FilterBar filters={filters} onChange={setFilters} />
<ProductList filters={filters} />
<FilterSummary filters={filters} />
</>
);
}
Pros:
- Shared state across components
- Single source of truth
- Easier to synchronize
- Better for testing (controlled from outside)
Cons:
- More prop drilling
- Parent re-renders affect all children
- Harder to maintain
- Less component reusability
Decision Matrix:
| Criteria | Local State | Lifted State |
|---|---|---|
| Used by single component | β Best | β Overkill |
| Used by siblings | β Impossible | β Best |
| Derived from props | β Avoid | β Good |
| Form input (controlled) | β οΈ Depends | β οΈ Depends |
| Temporary UI state | β Best | β Overkill |
| Needs persistence | β Hard | β Easier |
2. Props Drilling vs Context vs State Management Library
Props Drilling:
// β BAD: Deep prop drilling (5+ levels)
function App() {
const [user, setUser] = useState(null);
return <Dashboard user={user} onUpdateUser={setUser} />;
}
function Dashboard({ user, onUpdateUser }) {
return <Sidebar user={user} onUpdateUser={onUpdateUser} />;
}
function Sidebar({ user, onUpdateUser }) {
return <UserMenu user={user} onUpdateUser={onUpdateUser} />;
}
function UserMenu({ user, onUpdateUser }) {
return <UserProfile user={user} onUpdateUser={onUpdateUser} />;
}
function UserProfile({ user, onUpdateUser }) {
// Finally using the props here!
return <button onClick={() => onUpdateUser({ ...user, name: 'New' })}>
{user.name}
</button>;
}
Context API:
// β
GOOD: Context for widely-used data
const UserContext = createContext();
function App() {
const [user, setUser] = useState(null);
return (
<UserContext.Provider value={{ user, setUser }}>
<Dashboard />
</UserContext.Provider>
);
}
function UserProfile() {
const { user, setUser } = useContext(UserContext);
return <button onClick={() => setUser({ ...user, name: 'New' })}>
{user.name}
</button>;
}
Comparison:
| Aspect | Props | Context | Redux/Zustand |
|---|---|---|---|
| Complexity | Low | Medium | High |
| Boilerplate | None | Little | Significant |
| Performance | Best | Good* | Good |
| DevTools | Basic | Basic | Excellent |
| Time Travel | No | No | Yes |
| Middleware | No | No | Yes |
| Learning Curve | Easy | Easy | Steep |
*Context causes re-renders of all consumers when value changes
3. State Colocation Principles
// β BAD: State too high in tree
function App() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalData, setModalData] = useState(null);
const [isTooltipVisible, setIsTooltipVisible] = useState(false);
const [activeTab, setActiveTab] = useState(0);
// ... 20 more state variables
// Entire app re-renders when ANY state changes!
return <Dashboard /* all the state and setters */ />;
}
// β
GOOD: State colocated with usage
function App() {
return <Dashboard />; // No state here
}
function Dashboard() {
const [activeTab, setActiveTab] = useState(0);
return (
<Tabs value={activeTab} onChange={setActiveTab}>
<ProductTab />
<SettingsTab />
</Tabs>
);
}
function ProductTab() {
const [isModalOpen, setIsModalOpen] = useState(false);
// Modal state only here - doesn't affect SettingsTab
}
Colocation Benefits:
- Fewer re-renders (smaller component trees affected)
- Easier to understand (state near usage)
- Better code splitting (state moves with component)
- Easier refactoring (self-contained components)
When to Break Colocation:
- Multiple components need the state
- State needs to persist across unmounts
- State affects routing or URL
- State syncs with backend
- State is global configuration
4. Controlled vs Uncontrolled Components
Controlled (React State Controls Value):
function ControlledForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
// State is always in sync
console.log({ email, password });
};
return (
<form onSubmit={handleSubmit}>
<input
value={email}
onChange={e => setEmail(e.target.value)}
/>
<input
value={password}
onChange={e => setPassword(e.target.value)}
type="password"
/>
</form>
);
}
Pros: Full control, validation, conditional rendering, state always in sync Cons: More boilerplate, more re-renders
Uncontrolled (DOM Manages Value):
function UncontrolledForm() {
const emailRef = useRef();
const passwordRef = useRef();
const handleSubmit = (e) => {
e.preventDefault();
// Read values from DOM when needed
console.log({
email: emailRef.current.value,
password: passwordRef.current.value
});
};
return (
<form onSubmit={handleSubmit}>
<input ref={emailRef} defaultValue="" />
<input ref={passwordRef} type="password" defaultValue="" />
</form>
);
}
Pros: Less code, better performance, simpler for basic forms Cons: Less control, harder to validate, can't derive state
When to Use Each:
- Controlled: Forms with validation, conditional fields, instant feedback, derived state
- Uncontrolled: Simple forms, file inputs, integrating with non-React libraries
π¬ Explain to Juniorβ
Simple Analogy: Restaurant Order System
Imagine a restaurant:
State = Kitchen's Order List
- The kitchen (component) has its own list of orders (state)
- Only the kitchen can update this list (using setState/useState)
- When orders change, the kitchen updates what it's cooking (re-renders)
- If the kitchen closes (component unmounts), the list is thrown away
Props = Customer's Order
- When a customer (parent component) places an order, they tell the waiter (props) what they want
- The waiter can't change the customer's order - they just deliver it
- If the customer changes their mind, they tell the waiter the new order (parent re-renders with new props)
- The kitchen receives the order but can't modify it
Real Code Example:
// This is like a customer (parent) ordering food
function Restaurant() {
const [customerOrder, setCustomerOrder] = useState("burger");
return (
<div>
<button onClick={() => setCustomerOrder("pizza")}>
Change Order to Pizza
</button>
<Kitchen order={customerOrder} />
</div>
);
}
// This is like the kitchen (child) receiving the order
function Kitchen({ order }) {
const [cookingStatus, setCookingStatus] = useState("not started");
const startCooking = () => {
setCookingStatus("cooking");
setTimeout(() => setCookingStatus("ready"), 3000);
};
// β Can't do this - props are read-only
// order = "salad"; // Not allowed!
return (
<div>
<p>Cooking: {order}</p>
<p>Status: {cookingStatus}</p>
<button onClick={startCooking}>Start Cooking</button>
</div>
);
}
Key Insights for Beginners:
-
State is private, props are public
- State = Your diary (only you can write in it)
- Props = A letter someone sent you (you can read but not change)
-
When to use each:
- Use state when the component needs to remember something (form input, toggle state, counter)
- Use props when parent needs to tell child what to display (user name, configuration, data)
-
Common mistake:
// β WRONG: Trying to use props as initial statefunction BadCounter({ initialCount }) {const [count, setCount] = useState(initialCount);// Problem: If parent changes initialCount, this won't update!return <div>{count}</div>;}// β RIGHT: Either use props directly or sync with useEffectfunction GoodCounter({ count }) {// Just use the prop - parent controls itreturn <div>{count}</div>;} -
Mental Model:
- State = What the component remembers
- Props = What the component is told
- Re-render = Component recalculates what to show based on current state + props
Interview Answer Template:
"State and props are two ways components handle data in React. State is internal data that a component manages itself - like a counter or form input. When state changes using setState or useState, the component re-renders to show the new value. Props are data passed from a parent component and are read-only in the child - they flow one direction down the component tree. The key difference is ownership: state is owned and changed by the component, while props are owned by the parent. For example, [give counter vs display name example]. This distinction supports React's unidirectional data flow and makes it easier to reason about where data changes come from."
Common Interview Follow-ups:
Q: "Can you modify props?" A: "No, props are read-only. If a child needs to change data, the parent should pass a callback function as a prop that the child can call."
Q: "What happens when state changes?" A: "React schedules a re-render of the component and its children. During re-render, React calls the component function again with the new state, generates a new virtual DOM, compares it with the previous one (reconciliation), and updates only the parts of the real DOM that changed."
Q: "When would you use state vs props?" A: "Use state for data that changes within the component (user input, toggles, local UI state). Use props to pass data from parent to child, configure child components, or pass callback functions. If multiple components need the same data, lift the state up to their common parent and pass it down as props."
</details>
Question 2: What is lifting state up and when should you do it?β
Answerβ
Lifting state up is a React pattern where you move state from a child component to a parent component (or a common ancestor) so that multiple components can share and synchronize that state. Instead of each component managing its own copy of the data, the parent becomes the single source of truth and passes the state down as props.
When to lift state up:
- Multiple components need the same data - If two or more sibling components need to display or modify the same state
- Components need to stay synchronized - When changes in one component should immediately reflect in another
- Derived state from shared data - When multiple components compute different things from the same source data
- Parent needs to orchestrate child behavior - When the parent component needs to coordinate actions between children
Basic Example:
// β BEFORE: Separate state in each component (not synchronized)
function TemperatureInput() {
const [celsius, setCelsius] = useState('');
return <input value={celsius} onChange={e => setCelsius(e.target.value)} />;
}
// β
AFTER: Lifted state to parent (synchronized)
function TemperatureCalculator() {
const [temperature, setTemperature] = useState('');
return (
<div>
<TemperatureInput
value={temperature}
onChange={setTemperature}
/>
<BoilingVerdict celsius={parseFloat(temperature)} />
</div>
);
}
function TemperatureInput({ value, onChange }) {
return (
<input
value={value}
onChange={e => onChange(e.target.value)}
/>
);
}
The pattern is fundamental to React's architecture - it enforces unidirectional data flow, creates a single source of truth, and makes state changes predictable. While it introduces some prop drilling, the benefit of synchronized state usually outweighs the cost.
π Deep Diveβ
State Lifting Mechanics in React's Reconciliation:
When you lift state up, React's reconciliation algorithm handles the re-rendering efficiently:
// Parent component with lifted state
function Parent() {
const [sharedData, setSharedData] = useState({ count: 0 });
// When setSharedData is called:
// 1. React marks Parent fiber as needing update
// 2. Schedules re-render starting from Parent
// 3. During render, creates new props objects for children
// 4. Compares old vs new props for each child (shallow equality)
// 5. Re-renders children only if props actually changed
return (
<>
<ChildA data={sharedData} onChange={setSharedData} />
<ChildB data={sharedData} />
</>
);
}
React Fiber Traversal on State Update:
Update in ChildA β Calls onChange(newData)
β
Parent's setSharedData(newData)
β
Mark Parent fiber dirty
β
Begin work on Parent fiber
β
Render Parent (generates new children)
β
βββββββββββββ΄ββββββββββββ
β β
ChildA fiber ChildB fiber
(props changed) (props changed)
β β
Re-render both Re-render both
State Lifting Patterns:
1. Basic Lift (Single Level):
// Before: Independent state
function FilterButton() {
const [active, setActive] = useState(false);
return <button onClick={() => setActive(!active)}>Filter</button>;
}
function ProductList() {
const [active, setActive] = useState(false);
// Problem: Two separate states, not synchronized
}
// After: Lifted to parent
function FilteredProducts() {
const [filterActive, setFilterActive] = useState(false);
return (
<>
<FilterButton active={filterActive} onChange={setFilterActive} />
<ProductList showFiltered={filterActive} />
</>
);
}
2. Multi-Level Lift (Deep Tree):
// State needs to be at the common ancestor
function App() {
const [user, setUser] = useState(null);
return (
<div>
<Header user={user} /> {/* Shows user name */}
<Sidebar>
<UserMenu>
<LoginButton onLogin={setUser} /> {/* Needs to update user */}
</UserMenu>
</Sidebar>
<Content user={user} /> {/* Shows user-specific content */}
</div>
);
}
// All three branches need user state β lift to App
3. Computed State Derivation:
function ShoppingCart() {
const [items, setItems] = useState([]);
// Derived state computed from items
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const tax = subtotal * 0.1;
const total = subtotal + tax;
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
return (
<>
<CartHeader itemCount={itemCount} /> {/* Uses derived state */}
<CartItems items={items} onUpdate={setItems} />
<CartSummary subtotal={subtotal} tax={tax} total={total} />
</>
);
}
4. Controlled Component Pattern:
This is a specific case of lifting state where child becomes "controlled":
function ControlledInput({ value, onChange }) {
// No local state - fully controlled by parent
return (
<input
value={value}
onChange={e => onChange(e.target.value)}
/>
);
}
function Form() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
// Parent has full control over inputs
const handleSubmit = () => {
console.log({ email, password }); // Always in sync
};
return (
<form onSubmit={handleSubmit}>
<ControlledInput value={email} onChange={setEmail} />
<ControlledInput value={password} onChange={setPassword} />
</form>
);
}
Performance Considerations:
// β BAD: Lifting too high causes unnecessary re-renders
function App() {
const [modalOpen, setModalOpen] = useState(false);
return (
<div>
<Header /> {/* Re-renders unnecessarily */}
<Sidebar /> {/* Re-renders unnecessarily */}
<Content />
<Footer />
<Modal open={modalOpen} onClose={() => setModalOpen(false)} />
</div>
);
}
// β
GOOD: Lift only as high as needed
function App() {
return (
<div>
<Header />
<Sidebar />
<ContentWithModal /> {/* Modal state lifted only here */}
<Footer />
</div>
);
}
function ContentWithModal() {
const [modalOpen, setModalOpen] = useState(false);
return (
<>
<Content onOpenModal={() => setModalOpen(true)} />
<Modal open={modalOpen} onClose={() => setModalOpen(false)} />
</>
);
}
State Lifting vs Context:
// State lifting with props (good for 2-3 levels)
function Parent() {
const [theme, setTheme] = useState('light');
return <Child theme={theme} setTheme={setTheme} />;
}
function Child({ theme, setTheme }) {
return <GrandChild theme={theme} setTheme={setTheme} />;
}
function GrandChild({ theme, setTheme }) {
return <button onClick={() => setTheme('dark')}>{theme}</button>;
}
// Context (better for deep trees)
const ThemeContext = createContext();
function Parent() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<Child />
</ThemeContext.Provider>
);
}
function GrandChild() {
const { theme, setTheme } = useContext(ThemeContext);
return <button onClick={() => setTheme('dark')}>{theme}</button>;
}
Inverse Data Flow (Child to Parent Communication):
function Parent() {
const [data, setData] = useState([]);
// Child communicates up via callbacks
const handleChildUpdate = (newItem) => {
setData(prevData => [...prevData, newItem]);
};
const handleChildDelete = (id) => {
setData(prevData => prevData.filter(item => item.id !== id));
};
return (
<>
<ChildA onUpdate={handleChildUpdate} />
<ChildB items={data} onDelete={handleChildDelete} />
</>
);
}
function ChildA({ onUpdate }) {
const handleClick = () => {
// Child doesn't manage state - just calls parent
onUpdate({ id: Date.now(), text: 'New item' });
};
return <button onClick={handleClick}>Add</button>;
}
π Real-World Scenarioβ
Scenario: Real-Time Booking System State Synchronization
Context: A hotel booking platform (200K monthly users) had a critical issue where the room availability calendar and the booking form would show conflicting information. Users could select dates that appeared available in the calendar but failed at checkout because the booking form showed different availability. This resulted in:
- 12% booking abandonment rate (industry average: 5%)
- 450+ customer complaints per month
- $180K estimated monthly revenue loss
- Average time to discover issue: 3-4 minutes into booking flow
Root Cause:
Multiple components maintained their own copies of availability data:
// β BAD: Duplicate state across components
function BookingPage() {
return (
<div>
<AvailabilityCalendar />
<BookingForm />
<PricingSummary />
</div>
);
}
function AvailabilityCalendar() {
const [availability, setAvailability] = useState([]);
useEffect(() => {
// Fetches availability from API
fetchAvailability().then(setAvailability);
}, []);
const handleDateClick = (date) => {
// Updates local state only
setAvailability(prev => markDateAsSelected(prev, date));
};
return <Calendar dates={availability} onSelect={handleDateClick} />;
}
function BookingForm() {
const [availability, setAvailability] = useState([]);
const [selectedDates, setSelectedDates] = useState([]);
useEffect(() => {
// SAME API call, different component
fetchAvailability().then(setAvailability);
}, []);
// Problem: Doesn't know about calendar selections!
const isDateAvailable = (date) => {
return availability.some(d => d.date === date && d.available);
};
}
function PricingSummary() {
const [selectedDates, setSelectedDates] = useState([]);
// ANOTHER separate state for dates
// No way to know what calendar or form selected
}
Investigation Metrics:
-
State Analysis:
- Components with availability state: 3
- Total state variables tracking dates: 7
- API calls per page load: 3 (same endpoint!)
- Average time between API calls: 50-200ms
- Data synchronization lag: 500-2000ms
-
User Flow Breakdown:
- Step 1: User selects dates in calendar (state updated in Calendar)
- Step 2: User scrolls to booking form (state not updated there)
- Step 3: Form validates dates (uses stale data from initial fetch)
- Step 4: Pricing calculates (uses different selected dates)
- Step 5: Submit fails (backend has newer data)
-
Network Analysis:
// Three identical API calls on page loadGET /api/availability?room=101 // AvailabilityCalendarGET /api/availability?room=101 // BookingForm (50ms later)GET /api/availability?room=101 // PricingSummary (120ms later)// Race condition: If a booking happens between these calls,// different components get different availability data
Debugging Steps:
-
React DevTools Investigation:
<BookingPage><AvailabilityCalendar>state: { availability: [...50 items], selectedDates: [date1, date2] }</AvailabilityCalendar><BookingForm>state: { availability: [...48 items], selectedDates: [] }^^^^ Different data! ^^^^</BookingForm></React DevTools> -
Console Logging State:
// Added debugginguseEffect(() => {console.log('Calendar availability:', availability.length);}, [availability]);useEffect(() => {console.log('Form availability:', availability.length);}, [availability]);// Output:// Calendar availability: 50 (at 0ms)// Form availability: 48 (at 150ms) β Room booked between fetches! -
Timeline of Bug:
- 00:00ms: User loads page
- 00:20ms: Calendar fetches availability (gets 50 available dates)
- 00:150ms: Form fetches availability (room 301 just booked β 49 dates)
- 02:300ms: User selects dates including newly unavailable date
- 02:500ms: User proceeds to payment
- 02:800ms: Backend rejects booking (date unavailable)
Solution: Lift State Up
// β
GOOD: Single source of truth at parent
function BookingPage() {
// All state lifted to parent
const [availability, setAvailability] = useState([]);
const [selectedDates, setSelectedDates] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Single API call for entire page
useEffect(() => {
let mounted = true;
const loadAvailability = async () => {
try {
setLoading(true);
const data = await fetchAvailability();
if (mounted) {
setAvailability(data);
setError(null);
}
} catch (err) {
if (mounted) {
setError(err.message);
}
} finally {
if (mounted) {
setLoading(false);
}
}
};
loadAvailability();
// Poll for updates every 30 seconds
const interval = setInterval(loadAvailability, 30000);
return () => {
mounted = false;
clearInterval(interval);
};
}, []);
// Handle date selection (single source of truth)
const handleDateSelect = (dates) => {
setSelectedDates(dates);
};
// Handle booking from another user (WebSocket update)
useEffect(() => {
const socket = connectToBookingUpdates();
socket.on('booking_made', (bookedDate) => {
setAvailability(prev =>
prev.map(date =>
date.id === bookedDate.id
? { ...date, available: false }
: date
)
);
// Remove from selection if was selected
setSelectedDates(prev =>
prev.filter(date => date.id !== bookedDate.id)
);
});
return () => socket.disconnect();
}, []);
// Derived state
const totalPrice = useMemo(() =>
selectedDates.reduce((sum, date) => {
const priceData = availability.find(d => d.id === date.id);
return sum + (priceData?.price || 0);
}, 0),
[selectedDates, availability]
);
if (loading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
return (
<div>
<AvailabilityCalendar
availability={availability}
selectedDates={selectedDates}
onSelectDates={handleDateSelect}
/>
<BookingForm
availability={availability}
selectedDates={selectedDates}
onSelectDates={handleDateSelect}
/>
<PricingSummary
selectedDates={selectedDates}
totalPrice={totalPrice}
/>
</div>
);
}
// Child components become "controlled"
function AvailabilityCalendar({ availability, selectedDates, onSelectDates }) {
const handleDateClick = (date) => {
const isSelected = selectedDates.some(d => d.id === date.id);
if (isSelected) {
// Remove from selection
onSelectDates(selectedDates.filter(d => d.id !== date.id));
} else {
// Add to selection
onSelectDates([...selectedDates, date]);
}
};
return (
<Calendar
dates={availability}
selected={selectedDates}
onSelect={handleDateClick}
/>
);
}
function BookingForm({ availability, selectedDates, onSelectDates }) {
const handleSubmit = async (e) => {
e.preventDefault();
// Validation uses same state as calendar
const allAvailable = selectedDates.every(date => {
const dateData = availability.find(d => d.id === date.id);
return dateData?.available === true;
});
if (!allAvailable) {
alert('Some selected dates are no longer available');
return;
}
try {
await submitBooking(selectedDates);
} catch (error) {
if (error.code === 'DATE_UNAVAILABLE') {
// Refresh availability
alert('Dates changed. Please reselect.');
onSelectDates([]);
}
}
};
return (
<form onSubmit={handleSubmit}>
<DateList dates={selectedDates} />
<button type="submit">Book Now</button>
</form>
);
}
function PricingSummary({ selectedDates, totalPrice }) {
// Pure presentational component
return (
<div>
<p>Selected: {selectedDates.length} nights</p>
<p>Total: ${totalPrice}</p>
</div>
);
}
Results After Refactoring:
| Metric | Before | After | Improvement |
|---|---|---|---|
| Booking abandonment rate | 12% | 5.2% | 57% reduction |
| State sync issues | 450/month | 0 | 100% elimination |
| API calls per page load | 3 | 1 | 66% reduction |
| Customer complaints | 450/month | <10/month | 98% reduction |
| Revenue recovery | - | $165K/month | ~92% of losses |
| Average load time | 1.8s | 1.2s | 33% faster |
Additional Benefits:
- Real-time updates: WebSocket integration was trivial - update state in one place
- Testing: Single state made unit/integration tests 80% simpler
- Debugging: React DevTools showed single state tree (easy to trace bugs)
- Code reduction: Removed 300+ lines of duplicate state management logic
Lessons Learned:
- Duplicate state = guaranteed synchronization bugs
- Multiple API calls to same endpoint = race conditions
- Lift state to lowest common ancestor
- Use derived state instead of duplicate computed state
- WebSocket/polling updates are easier with single source of truth
</details>
<details> <summary><strong>βοΈ Trade-offs: Lifting State - When and How Much</strong></summary>
1. When to Lift State vs When to Keep Local
Lift State Up When:
// β
Multiple components need the data
function SearchPage() {
const [query, setQuery] = useState('');
return (
<>
<SearchInput value={query} onChange={setQuery} />
<SearchResults query={query} />
<SearchStats query={query} />
<RecentSearches onSelectRecent={setQuery} />
</>
);
}
// β
Components need to stay synchronized
function TemperatureConverter() {
const [celsius, setCelsius] = useState(0);
return (
<>
<CelsiusInput value={celsius} onChange={setCelsius} />
<FahrenheitInput
value={celsius * 9/5 + 32}
onChange={f => setCelsius((f - 32) * 5/9)}
/>
</>
);
}
Keep Local When:
// β
Only one component needs the data
function Dropdown() {
const [isOpen, setIsOpen] = useState(false);
// No other component cares if dropdown is open
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{isOpen && <DropdownMenu />}
</div>
);
}
// β
Temporary UI state
function ImageGallery() {
const [hoveredIndex, setHoveredIndex] = useState(null);
// Hover state is purely visual, local to this component
return images.map((img, i) => (
<img
key={i}
src={img}
onMouseEnter={() => setHoveredIndex(i)}
style={{ opacity: hoveredIndex === i ? 1 : 0.7 }}
/>
));
}
Decision Matrix:
| Scenario | Keep Local | Lift Up |
|---|---|---|
| Single component uses state | β Yes | β No |
| Siblings need same data | β No | β Yes |
| Derived state from same source | β No | β Yes |
| Temporary UI state (hover, focus) | β Yes | β No |
| Form input values | β οΈ Depends | β οΈ Depends |
| Data from API | β No* | β Yes* |
| Modal open/closed | β οΈ Depends | β οΈ Depends |
*Usually lift to enable caching, refetching, error handling
2. Prop Drilling vs Context API vs State Management
Prop Drilling (2-3 levels deep):
// β
GOOD: Shallow tree (2-3 levels)
function App() {
const [user, setUser] = useState(null);
return <Dashboard user={user} onLogout={() => setUser(null)} />;
}
function Dashboard({ user, onLogout }) {
return <Header user={user} onLogout={onLogout} />;
}
function Header({ user, onLogout }) {
return <UserMenu user={user} onLogout={onLogout} />;
}
Pros: Explicit, easy to trace, no magic Cons: Verbose, intermediate components become "prop proxies"
Context API (4+ levels or widely used data):
// β
GOOD: Deep tree or many consumers
const UserContext = createContext();
function App() {
const [user, setUser] = useState(null);
return (
<UserContext.Provider value={{ user, setUser }}>
<Dashboard />
</UserContext.Provider>
);
}
function Header() {
// Skip intermediate components
const { user, setUser } = useContext(UserContext);
return <UserMenu user={user} onLogout={() => setUser(null)} />;
}
Pros: No prop drilling, cleaner intermediate components Cons: Less explicit, all consumers re-render on any context change
State Management Library (Complex apps):
// Zustand example
const useUserStore = create((set) => ({
user: null,
setUser: (user) => set({ user }),
logout: () => set({ user: null })
}));
function Header() {
const { user, logout } = useUserStore();
return <UserMenu user={user} onLogout={logout} />;
}
Pros: Best performance, DevTools, middleware Cons: Extra dependency, learning curve
When to Use Each:
| Depth | Components Using State | Recommendation |
|---|---|---|
| 1-2 levels | 1-2 | Props |
| 2-3 levels | 2-5 | Props or Context |
| 3+ levels | 3-10 | Context API |
| Any depth | 10+ | State library |
3. Lifting State vs Composition
Sometimes you can avoid lifting state by using component composition:
// β Lifting state (works but verbose)
function Page() {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<Layout
sidebarOpen={sidebarOpen}
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
>
<Content />
</Layout>
);
}
function Layout({ sidebarOpen, onToggleSidebar, children }) {
return (
<div>
<Sidebar isOpen={sidebarOpen} onClose={() => onToggleSidebar()} />
{children}
</div>
);
}
// β
Composition (cleaner)
function Page() {
return (
<Layout>
<Content />
</Layout>
);
}
function Layout({ children }) {
// State stays local - doesn't need to be lifted
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div>
<button onClick={() => setSidebarOpen(!sidebarOpen)}>
Toggle Sidebar
</button>
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
{children}
</div>
);
}
Composition Benefits:
- Less prop drilling
- State stays close to usage
- Components more reusable
4. Performance Impact of Lifting State
Problem: Lifting Too High
// β BAD: State at App level affects entire tree
function App() {
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
// Entire app re-renders on every mouse move!
useEffect(() => {
const handler = (e) => setMousePosition({ x: e.clientX, y: e.clientY });
window.addEventListener('mousemove', handler);
return () => window.removeEventListener('mousemove', handler);
}, []);
return (
<div>
<Header /> {/* Unnecessary re-render */}
<Sidebar /> {/* Unnecessary re-render */}
<Content />
<MouseTracker position={mousePosition} />
</div>
);
}
// β
GOOD: State only where needed
function App() {
return (
<div>
<Header />
<Sidebar />
<Content />
<MouseTrackerContainer /> {/* State isolated here */}
</div>
);
}
function MouseTrackerContainer() {
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handler = (e) => setMousePosition({ x: e.clientX, y: e.clientY });
window.addEventListener('mousemove', handler);
return () => window.removeEventListener('mousemove', handler);
}, []);
return <MouseTracker position={mousePosition} />;
}
Optimization Techniques:
// 1. Memoization
const MemoizedChild = React.memo(function Child({ data }) {
return <div>{data.name}</div>;
});
// 2. Split state
function Parent() {
const [frequentlyChanging, setFrequentlyChanging] = useState(0);
const [rarelyChanging, setRarelyChanging] = useState('');
return (
<>
<FastComponent value={frequentlyChanging} />
<SlowComponent value={rarelyChanging} /> {/* Won't re-render often */}
</>
);
}
// 3. Context splitting
const ThemeContext = createContext();
const UserContext = createContext(); // Separate contexts
function App() {
const [theme, setTheme] = useState('light'); // Changes rarely
const [user, setUser] = useState(null); // Changes often
return (
<ThemeContext.Provider value={theme}>
<UserContext.Provider value={user}>
<Layout />
</UserContext.Provider>
</ThemeContext.Provider>
);
}
π¬ Explain to Juniorβ
Simple Analogy: School Group Project
Imagine you and two classmates are working on a group project:
β WITHOUT Lifting State (Everyone has their own notes):
- You write down: "Project due date: May 15"
- Classmate 1 writes down: "Project due date: May 16"
- Classmate 2 writes down: "Project due date: May 14"
- Everyone shows up on different days! π±
β WITH Lifting State (One shared whiteboard):
- Teacher writes on whiteboard: "Project due date: May 15"
- Everyone reads from the SAME whiteboard
- When date changes, teacher updates whiteboard once
- Everyone sees the correct date automatically
Code Example:
// β BAD: Each component has its own "notes"
function ComponentA() {
const [count, setCount] = useState(0);
return <div>Count: {count} <button onClick={() => setCount(count + 1)}>+</button></div>;
}
function ComponentB() {
const [count, setCount] = useState(0);
// Different count! Not synchronized with ComponentA
return <div>Count: {count}</div>;
}
function App() {
return (
<>
<ComponentA /> {/* Has its own count */}
<ComponentB /> {/* Has different count */}
</>
);
}
// β
GOOD: Parent is the "whiteboard" (single source of truth)
function App() {
const [count, setCount] = useState(0); // Parent owns the state
return (
<>
<ComponentA count={count} onIncrement={() => setCount(count + 1)} />
<ComponentB count={count} />
</>
);
}
function ComponentA({ count, onIncrement }) {
// Uses parent's count (reads from "whiteboard")
// Calls parent's function to update
return (
<div>
Count: {count}
<button onClick={onIncrement}>+</button>
</div>
);
}
function ComponentB({ count }) {
// Also uses parent's count (reads same "whiteboard")
return <div>Count: {count}</div>;
}
Step-by-Step Lifting Process:
Step 1: Identify the problem
// Two components need the same data
<FilterButton /> {/* Needs to know which filter is active */}
<ProductList /> {/* Needs to know which filter to apply */}
Step 2: Find common parent
<App>
<Sidebar>
<FilterButton /> β Need active filter
</Sidebar>
<Content>
<ProductList /> β Need active filter
</Content>
</App>
Common parent: App
Step 3: Move state to parent
function App() {
const [activeFilter, setActiveFilter] = useState('all'); // State moved here
return (
<>
<FilterButton
active={activeFilter}
onChange={setActiveFilter}
/>
<ProductList filter={activeFilter} />
</>
);
}
Step 4: Make children "controlled"
function FilterButton({ active, onChange }) {
// No useState here - parent controls us
return (
<button onClick={() => onChange('new-filter')}>
Active: {active}
</button>
);
}
function ProductList({ filter }) {
// No useState here - parent tells us what to show
return <div>Showing: {filter} products</div>;
}
Common Beginner Mistakes:
Mistake 1: Trying to share state directly between siblings
// β WRONG: Can't do this!
function ComponentA() {
const [count, setCount] = useState(0);
}
function ComponentB() {
// β How do I get ComponentA's count here?
// Answer: You can't! Need to lift to parent
}
Mistake 2: Lifting too high
// β BAD: State at App level when only two components need it
function App() {
const [modalOpen, setModalOpen] = useState(false); // Too high!
return (
<>
<Header />
<Sidebar />
<Content />
<Footer>
<Modal open={modalOpen} />
</Footer>
</>
);
}
// β
GOOD: Lift only to Footer (common parent)
function Footer() {
const [modalOpen, setModalOpen] = useState(false);
return <Modal open={modalOpen} />;
}
Mental Model:
Think of state like a balloon π:
- Keep it low (local state) when only one person needs to hold it
- Lift it up when multiple people (components) need to see/touch it
- Don't lift it too high (to the ceiling) or it's hard to reach
Interview Answer Template:
"Lifting state up is moving state from a child component to a parent component so multiple children can share it. You do this when two or more sibling components need access to the same data or need to stay synchronized. For example, [give filter + product list example]. Instead of each component having its own state that can get out of sync, you move the state to their common parent. The parent becomes the single source of truth, passing data down as props and callbacks up for updates. This is a core React pattern because it enforces unidirectional data flow and prevents synchronization bugs."
Common Interview Follow-ups:
Q: "What's the downside of lifting state?" A: "Prop drilling - you might have to pass props through intermediate components that don't use them. If the tree is deep, consider using Context API or a state management library instead."
Q: "How high should you lift state?" A: "Only as high as the lowest common ancestor of components that need it. Lifting too high causes unnecessary re-renders of components that don't use the state."
Q: "Can you give an example of when NOT to lift state?" A: "When state is truly local to one component, like hover state, focus state, or a dropdown's open/closed state - unless parent needs to control these for some reason."
</details>
Source: state-01-management.md