Chapter 10: Lifting State Up
Once you master this, you’ll stop fighting with props drilling and your components will feel much more organized.
Let’s go step-by-step, like I’m sitting next to you in Mumbai with our laptops open, chai on the table, and I’m explaining it live with clear, practical examples.
1. What is “Lifting State Up”?
Lifting state up means: When two or more components need to share the same piece of state and react to its changes, you should move (lift) that state to their closest common parent component.
Why?
- React has one-way data flow: data flows down from parent to child via props.
- Children cannot directly change parent state.
- So if two siblings (or cousins) need the same data, the parent becomes the “single source of truth”.
Analogy: Imagine two kids (two child components) want to play with the same toy (state). If each kid has their own toy copy, they play separately. But if they want to share and see each other’s changes → the parent (common ancestor) holds the toy and gives it to both kids.
2. Classic Example: Temperature Converter (Sharing State Between Siblings)
We’ll build two input fields:
- Celsius input
- Fahrenheit input
When you type in one → the other updates automatically.
Wrong way (common beginner mistake): Each input has its own state → they don’t talk to each other.
Correct way: Lift the temperature state to the parent component.
Create src/components/TemperatureConverter.tsx
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 |
import { useState } from 'react'; // Two child components function CelsiusInput({ temperature, onTemperatureChange }: { temperature: string; onTemperatureChange: (value: string) => void; }) { return ( <div style={{ margin: '20px 0' }}> <label>Celsius: </label> <input type="number" value={temperature} onChange={(e) => onTemperatureChange(e.target.value)} style={{ padding: '10px', fontSize: '18px', width: '200px' }} /> </div> ); } function FahrenheitInput({ temperature, onTemperatureChange }: { temperature: string; onTemperatureChange: (value: string) => void; }) { return ( <div style={{ margin: '20px 0' }}> <label>Fahrenheit: </label> <input type="number" value={temperature} onChange={(e) => onTemperatureChange(e.target.value)} style={{ padding: '10px', fontSize: '18px', width: '200px' }} /> </div> ); } // Parent component - holds the shared state function TemperatureConverter() { const [celsius, setCelsius] = useState(''); // Convert functions const toFahrenheit = (c: string) => { if (c === '') return ''; const num = parseFloat(c); return isNaN(num) ? '' : ((num * 9/5) + 32).toFixed(1); }; const toCelsius = (f: string) => { if (f === '') return ''; const num = parseFloat(f); return isNaN(num) ? '' : ((num - 32) * 5/9).toFixed(1); }; const handleCelsiusChange = (value: string) => { setCelsius(value); }; const handleFahrenheitChange = (value: string) => { // When Fahrenheit changes, convert to Celsius and update state setCelsius(toCelsius(value)); }; const fahrenheit = toFahrenheit(celsius); return ( <div style={{ padding: '40px', maxWidth: '600px', margin: '0 auto', textAlign: 'center', background: '#f8f9ff', borderRadius: '16px', boxShadow: '0 8px 30px rgba(0,0,0,0.1)' }}> <h2 style={{ color: '#646cff' }}>Temperature Converter</h2> <p style={{ color: '#666', marginBottom: '30px' }}> Type in one field — the other updates automatically! </p> <CelsiusInput temperature={celsius} onTemperatureChange={handleCelsiusChange} /> <FahrenheitInput temperature={fahrenheit} onTemperatureChange={handleFahrenheitChange} /> <p style={{ marginTop: '40px', fontSize: '18px' }}> {celsius !== '' && ( <> {parseFloat(celsius) >= 100 ? '🔥 Water boils!' : 'Water is liquid ☕'} </> )} </p> </div> ); } export default TemperatureConverter; |
Key points here:
- State celsius lives in the parent
- Both child inputs get the value via props
- Both children call callbacks (onTemperatureChange) to tell the parent to update the state
- Parent re-renders → both children get the new value
3. Best Practices for State Management (2026 Style)
| Situation | Where to Put State | Why / Best Practice |
|---|---|---|
| State used only by one component | Inside that component (local state) | Keep it simple & encapsulated |
| State needed by multiple siblings | Lift to closest common parent | Single source of truth |
| State needed by deeply nested components | Lift to top (or use Context) | Avoid prop drilling |
| Very complex / global state | Use Context + useReducer, or libraries like Zustand, Jotai, Redux Toolkit | Scalable & clean |
| Form state with many fields | One object in parent or React Hook Form / Formik | Easier validation & submission |
| Data fetched from API | Lift to parent that needs it, or use React Query / SWR | Caching, loading, error handling |
Quick rule of thumb:
“Find the closest common parent that needs the data, and put the state there.”
4. Another Real-World Example: Shopping Cart Counter (Shared Across Header & Product)
Imagine:
- A header with cart icon showing number of items
- A product list where you can add items to cart
State needs to be shared between Header and ProductList.
Create src/components/ShoppingCartDemo.tsx
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
import { useState } from 'react'; function CartHeader({ count }: { count: number }) { return ( <header style={{ background: '#646cff', color: 'white', padding: '16px', textAlign: 'center', fontSize: '24px', position: 'sticky', top: 0 }}> My Shop 🛒 Cart: {count} items </header> ); } function ProductCard({ name, onAdd }: { name: string; onAdd: () => void }) { return ( <div style={{ border: '1px solid #ddd', borderRadius: '12px', padding: '20px', margin: '20px', textAlign: 'center', background: '#fff' }}> <h3>{name}</h3> <button onClick={onAdd} style={{ padding: '10px 24px', background: '#4ecdc4', color: 'white', border: 'none', borderRadius: '8px', cursor: 'pointer', fontSize: '16px' }} > Add to Cart </button> </div> ); } function ShoppingCartDemo() { const [cartCount, setCartCount] = useState(0); const addToCart = () => { setCartCount(prev => prev + 1); }; const products = ['Laptop', 'Phone', 'Headphones', 'Watch', 'Keyboard']; return ( <div> <CartHeader count={cartCount} /> <div style={{ padding: '40px', display: 'flex', flexWrap: 'wrap', justifyContent: 'center' }}> {products.map(product => ( <ProductCard key={product} name={product} onAdd={addToCart} /> ))} </div> <p style={{ textAlign: 'center', fontSize: '20px', marginTop: '40px' }}> Total items in cart: <strong>{cartCount}</strong> </p> </div> ); } export default ShoppingCartDemo; |
Summary – Chapter 10 Key Takeaways
- Lifting state up = move shared state to the closest common parent
- Parent holds state → passes value down via props
- Children send updates up via callback functions (onXXXChange)
- This creates single source of truth → no bugs from duplicated state
- For very deep nesting → consider Context (next chapters) or modern libraries
- Rule: “State should live as close as possible to where it’s needed, but high enough so everyone who needs it can access it.”
Mini Homework
- Build an Accordion component with 3 questions.
- Only one section can be open at a time.
- Lift the “which section is open” state to the parent Accordion component.
- Each Question component gets isOpen prop and onToggle callback.
