Lifting State Up
When two components need the same state, move it up to their closest common parent - value down, changes up. Then see, with counted re-renders, what happens when state climbs too high, and three ways back.
What you will be able to do
- Recognise when two components need to share state
- Lift state in three steps: remove it from the child, add it to the closest common parent, pass value down and a callback up
- Explain "value down, changes up" - one-way data flow with callbacks
- Find the closest common parent of two components
- Measure what happens when state is lifted higher than needed
- Choose a fix when lifting stops scaling: move it down, memo, or a store
- Store what the user typed and derive the rest, so lifted inputs stay correct
The idea, in plain English
In Lesson 1 you fixed the "two copies drift apart" bug by keeping one copy in a parent and passing it down. That move has a name: lifting state up. It is the first answer React gives to shared state, it needs no library, and it is the right answer more often than people think.
The idea is simple. When two components need the same piece of state, neither of them should own it. Move it up to the closest component that contains both - their closest common parent. The parent keeps the state, passes the value down as a prop, and passes a function down so the children can ask for changes. Data still flows in one direction: values go down, change requests come up.
Lifting has one weakness. The owner re-renders every time the state changes, and by default React re-renders everything below the owner. If the state climbs higher than it needs to - all the way to App, say - every key press re-renders screens that have nothing to do with it. That is the example in the course outline, and in this lesson we count those re-renders.
All code was run with React 19.3, react-redux 9.3.0 and Redux Toolkit 2.13.0, rendered in a test browser (jsdom). We typed into the inputs and counted every render.
Worked example: State that climbed so high every screen re-renders: a search box’s text lifted to App, re-rendering 20 unrelated widgets on every key.
1 - Local: only SearchBox has the text
The text lives in SearchBox’s useState. It works for the box - but CourseList cannot see it, so it shows all five courses whatever you type.
A search page, step by step. Each time a new component needs the search text, the state moves up - and more of the app re-renders when you type.
The parent owns the state. Children get the value as a prop. To change it, a child calls the function the parent gave it.
The problem: two siblings need the same data
A search page has two components side by side: SearchBox, where you type, and CourseList, which should show only the matching courses. In the first version, the text lives in SearchBox’s own useState - local state, as Lesson 2 recommends starting.
It does not work. We typed "re", and the list still showed all five courses. CourseList has no way to read another component’s useState. In React, a component can only receive data from above, through props. Two siblings cannot talk to each other directly.
import { useState } from "react";
const COURSES = ["Python", "Redux", "React", "Docker", "Pandas"];
function SearchBox() {
const [query, setQuery] = useState(""); // only SearchBox knows the text
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
function CourseList() {
// CourseList needs the text to filter - but it cannot see it
return <ul>{COURSES.map((c) => <li key={c}>{c}</li>)}</ul>;
}
export default function SearchPage() {
return (
<section>
<SearchBox />
<CourseList />
</section>
);
}list shows: Python, Redux, React, Docker, Pandas <- not filteredAn everyday picture: one guest count on the kitchen board
Two cooks in a restaurant kitchen each keep their own count of how many guests are coming. One cook hears that two more guests arrived; the other does not. Now one cooks for 12 and the other for 10.
The fix is not for the cooks to keep chasing each other. The head chef, who manages both of them, writes the guest count on the kitchen board. Both cooks read the board. When a cook learns about new guests, they do not change the board themselves - they tell the head chef, and the head chef updates it.
That is lifting state up. The head chef is the closest common parent. The board is the state. Reading the board is "value down"; telling the head chef is "changes up".
Lifting state in three steps
Step 1: remove the state from the child. SearchBox no longer has useState. Step 2: add it to the closest common parent. SearchPage now has const [query, setQuery] = useState(""). Step 3: pass it down. Both children get query as a prop; SearchBox also gets onQueryChange, a function it calls when the user types.
We typed "re" again. This time the list showed only Redux and React. SearchBox no longer decides what the text is - it shows the value it is given and reports changes. A component like this, whose value comes from its parent, is called a controlled component.
import { useState } from "react";
const COURSES = ["Python", "Redux", "React", "Docker", "Pandas"];
function SearchBox({ query, onQueryChange }) { // receives the value and a way to change it
return <input value={query} onChange={(e) => onQueryChange(e.target.value)} />;
}
function CourseList({ query }) { // receives the value
const shown = COURSES.filter((c) => c.toLowerCase().includes(query.toLowerCase()));
return <ul>{shown.map((c) => <li key={c}>{c}</li>)}</ul>;
}
export default function SearchPage() {
const [query, setQuery] = useState(""); // lifted: the closest common parent owns it
return (
<section>
<SearchBox query={query} onQueryChange={setQuery} />
<CourseList query={query} />
</section>
);
}list shows: Redux, React
renders while typing 2 keys: SearchPage 2, SearchBox 2, CourseList 2 (total 6)1. Remove it from the childDelete useState from SearchBox.2. Add it to the closest common parentconst [query, setQuery] = useState("") in SearchPage.3. Pass value down, callback downquery to both children; onQueryChange={setQuery} to SearchBox.Value down, changes up
Look at the second diagram. Data in React flows in one direction: from parent to child, as props. Lifting state does not break that rule. The value still only goes down.
So how does SearchBox change the text? The parent gives it a function - onQueryChange - and SearchBox calls it with the new text. The parent decides what to do with it (here, store it with setQuery), re-renders, and sends the new value down to both children. The child asks; the owner decides. This is the same idea as Redux’s "dispatch an action" from Lesson 1, in a smaller form.
A naming habit that helps: call the prop onSomethingChange (or onSelect, onAdd). Then anyone reading <SearchBox query={query} onQueryChange={setQuery} /> can see at once which value comes down and which function goes up.
Finding the closest common parent
Start at each component that needs the state, and walk up the tree towards App. The first component that both paths reach is the closest common parent. That is where the state should live - not higher.
Lifting higher than that is not "safer". As the next topic shows, every level you go up makes more of the app re-render on each change, and makes more components pass the value along (Lesson 5).
SearchBox + CourseListBoth are inside SearchPage -> lift to SearchPage.SearchBox + HeaderTheir paths only meet at App -> lift to App (and see the fixes below).Celsius input + Fahrenheit inputBoth inside Converter -> lift to Converter (the challenge).Two cards in the same listBoth inside the list -> lift to the list, e.g. "which card is open".Where it stops scaling: state that climbed too high
This is the example from the course outline. The search text is the same, but now it lives in App. App also renders a Header, a Dashboard with 20 widgets, and a Footer. None of them use the search text.
We typed the same two keys. The screen looked exactly the same as before - but the number of renders went from 6 to 54. Every key press re-rendered App, and React re-rendered everything below App: the Header, the Dashboard, all 20 widgets (40 renders), and the Footer. In a real app, those "widgets" are charts, tables and images, and the page starts to feel slow while you type.
How does state end up this high? Usually one step at a time. One day the Header wants to show "Results for re". Another day someone lifts it "just in case". Each move is small; nobody moves it back.
import { useState } from "react";
const COURSES = ["Python", "Redux", "React", "Docker", "Pandas"];
function Header() { return <header>Right Tech Engineering</header>; }
function Widget({ n }) { return <div>Widget {n}</div>; }
function Dashboard() {
return <div>{Array.from({ length: 20 }, (_, i) => <Widget key={i} n={i} />)}</div>;
}
function Footer() { return <footer>© 2026</footer>; }
function SearchBox({ query, onQueryChange }) {
return <input value={query} onChange={(e) => onQueryChange(e.target.value)} />;
}
function CourseList({ query }) {
const shown = COURSES.filter((c) => c.toLowerCase().includes(query.toLowerCase()));
return <ul>{shown.map((c) => <li key={c}>{c}</li>)}</ul>;
}
function SearchPage({ query, onQueryChange }) {
return (
<section>
<SearchBox query={query} onQueryChange={onQueryChange} />
<CourseList query={query} />
</section>
);
}
export default function App() {
const [query, setQuery] = useState(""); // climbed all the way to the top
return (
<>
<Header />
<Dashboard />
<SearchPage query={query} onQueryChange={setQuery} />
<Footer />
</>
);
} SearchPage owns it App owns it
App - 2
Header 0 2
Dashboard 0 2
Widget (x20) 0 40
Footer 0 2
SearchPage 2 2
SearchBox 2 2
CourseList 2 2
total 6 54Fix 1: move it back down
If nothing above SearchPage really needs the text, the best fix is to undo the climb: move useState back into SearchPage, the closest common parent. App goes back to having no state at all. We measured 6 renders again, and the code is shorter - SearchPage no longer needs props.
This is the most common fix in real projects, and the cheapest. Before reaching for any tool, ask: does every component above this one really need the value?
function SearchPage() {
const [query, setQuery] = useState(""); // moved back down
return (
<section>
<SearchBox query={query} onQueryChange={setQuery} />
<CourseList query={query} />
</section>
);
}
export default function App() { // no state any more
return (
<>
<Header />
<Dashboard />
<SearchPage />
<Footer />
</>
);
}
// measured: 6 renders for 2 keysFix 2 and 3: when it really must be high
Sometimes the closest common parent really is near the top - for example, when the Header shows "Results for re" and the search box is far below it. Then moving the state down is not possible. There are two common fixes.
Fix 2, memo: wrap the components that do not use the state in memo. memo tells React: "if this component’s props did not change, skip re-rendering it". Header, Dashboard and Footer take no props, so React skipped them - and all 20 widgets below the Dashboard. We measured 8 renders: App, SearchPage, SearchBox and CourseList, twice each. memo only works when the props really stay the same; a new object or function created on every render counts as a change.
Fix 3, a store: keep the text in Redux. SearchBox and CourseList each read it with useSelector, and the store tells only them about changes. App does not hold the state, so App, SearchPage and everything else do not re-render. We measured 4 renders: SearchBox and CourseList, twice each. This is the most work to set up, and Lesson 8 helps you decide when it is worth it.
import { memo, useState } from "react";
const Header = memo(function Header() {
return <header>Right Tech Engineering</header>;
});
const Dashboard = memo(function Dashboard() {
return <div>{Array.from({ length: 20 }, (_, i) => <Widget key={i} n={i} />)}</div>;
});
const Footer = memo(function Footer() {
return <footer>© 2026</footer>;
});
// Widget, SearchBox, CourseList, SearchPage and App: the same as TooHigh.jsx.
// App still owns the query - but memo skips Header, Dashboard and Footer.
// measured: 8 renders for 2 keys (App 2, SearchPage 2, SearchBox 2, CourseList 2)import { configureStore, createSlice } from "@reduxjs/toolkit";
import { Provider, useDispatch, useSelector } from "react-redux";
const COURSES = ["Python", "Redux", "React", "Docker", "Pandas"];
const searchSlice = createSlice({
name: "search",
initialState: { query: "" },
reducers: {
queryChanged(state, action) {
state.query = action.payload;
},
},
});
const store = configureStore({ reducer: { search: searchSlice.reducer } });
function Header() { return <header>Right Tech Engineering</header>; }
function Widget({ n }) { return <div>Widget {n}</div>; }
function Dashboard() {
return <div>{Array.from({ length: 20 }, (_, i) => <Widget key={i} n={i} />)}</div>;
}
function Footer() { return <footer>© 2026</footer>; }
function SearchBox() {
const query = useSelector((state) => state.search.query);
const dispatch = useDispatch();
return <input value={query} onChange={(e) => dispatch(searchSlice.actions.queryChanged(e.target.value))} />;
}
function CourseList() {
const query = useSelector((state) => state.search.query);
const shown = COURSES.filter((c) => c.toLowerCase().includes(query.toLowerCase()));
return <ul>{shown.map((c) => <li key={c}>{c}</li>)}</ul>;
}
function SearchPage() {
return (
<section>
<SearchBox />
<CourseList />
</section>
);
}
export default function App() {
return (
<Provider store={store}>
<Header />
<Dashboard />
<SearchPage />
<Footer />
</Provider>
);
}
// measured: 4 renders for 2 keys (SearchBox 2, CourseList 2)Lifted to SearchPage6 - the closest common parent.Lifted to App54 - every component re-renders.App + memo8 - the unrelated parts are skipped.Redux store4 - only the two readers re-render.Signs that lifting has stopped scaling
Lifting state up is the right first answer. These signs tell you it is time to think about the next one - Context (Lesson 7) or a store (Lesson 8).
The owner is near the topTyping or clicking re-renders large parts of the app.Long prop chainsThe value and its callback travel through many components that ignore them (Lesson 5).A crowded ownerApp has ten useState calls for unrelated features, and ten callbacks to pass down.Callbacks passed through callbacksA child calls onChange, which calls another onChange, three levels up.Many readers far apartThe same state is read in the header, a page, a modal and the footer.Common questions
Is lifting state the same as global state? No. Lifted state still belongs to one component - the closest common parent - and only its children can use it. Global state (Lesson 2) is available to the whole app.
Does lifting always cause more re-renders? Only below the owner. Lifting to SearchPage re-rendered SearchPage and its two children - 6 renders - which is nothing to worry about. The cost appears when the owner is high in the tree.
Should I wrap every component in memo, to be safe? No. memo adds a comparison on every render, and it silently stops working when a prop is a new object or function each time. Use it where you have measured a problem, as in Fix 2.
Can I lift state and still keep a local draft? Yes - a child can keep its own temporary state (for example, text being edited) and only send the final value up when the user presses Save. Then name it clearly, as Lesson 4 warned.
This lesson at a glance
Lifting state upMove state to the closest common parent of the components that need it.
Value downPass the state as a prop.
<CourseList query={query} />Changes upPass a function the child calls.
<SearchBox onQueryChange={setQuery} />Controlled componentIts value comes from the parent.
<input value={query} onChange={...} />memoSkip re-rendering when props did not change.
const Header = memo(function Header() {...})StoreOnly subscribed components re-render.
useSelector((state) => state.search.query)
Try it yourself
The code does not change. Swap the content string and the program does something else entirely.
“Run Before.jsx and type "py". Why does the list not change?”
“In TooHigh.jsx, change the Dashboard to 200 widgets. How many renders do 2 key presses cause now?”
“In the memo version, pass style={{ color: "red" }} to Header. Does memo still skip it? Why not?”
“Make Header show "Results for <query>". Where must the state live now? Which fix would you choose?”
What usually goes wrong
The other sibling cannot read it. Move it to their closest common parent.
✗ function SearchBox() { const [query, setQuery] = useState(""); ... }✓ function SearchPage() { const [query, setQuery] = useState(""); ... }Every level up re-renders more of the app on each change. We measured 54 renders instead of 6.
✗ function App() { const [query, setQuery] = useState(""); ... }✓ function SearchPage() { const [query, setQuery] = useState(""); ... }A child cannot change its props. Give it a callback and let the owner decide.
✗ props.query = e.target.value;✓ onQueryChange(e.target.value);In a converter, store what the user typed and in which box; calculate the other. Two stored values can disagree.
✗ const [celsius, setCelsius] = useState("");
const [fahrenheit, setFahrenheit] = useState("");✓ const [temp, setTemp] = useState({ value: "", scale: "c" });Practice
Write these yourself before opening anything. Getting them wrong first is most of how this sticks.
A ProductPage has a ColourPicker and a ProductImage. The image must show the chosen colour. Where should the chosen colour live, and what does each child receive?
Show hintHide hint
Find the closest common parent. Which child shows the value, and which one changes it?
Show solutionHide solution
function ProductPage() {
const [colour, setColour] = useState("black"); // the closest common parent
return (
<>
<ColourPicker colour={colour} onColourChange={setColour} />
<ProductImage colour={colour} />
</>
);
}In TooHigh.jsx, the search text is in App, but only SearchPage, SearchBox and CourseList use it. Which fix would you choose, and why?
Show hintHide hint
Does anything above SearchPage need the text?
Show solutionHide solution
Fix 1 - move it back down into SearchPage.
Nothing above SearchPage uses the text, so SearchPage is the closest
common parent. No memo and no store needed: 6 renders instead of 54,
and App and SearchPage get simpler.Name the three steps of lifting state up.
Show hintHide hint
Remove, add, pass.
Show solutionHide solution
1. Remove the state from the child.
2. Add it to the closest common parent.
3. Pass the value down as a prop, and a callback down so the child can request changes.A Celsius / Fahrenheit converter
Two inputs, one for Celsius and one for Fahrenheit. Typing in either one must update the other. In the starter each input has its own state, so they never agree. Lift the state up.
- One source of truth in the parent - not two useState values that must be kept equal
- Typing 100 in Celsius shows 212 in Fahrenheit; typing 32 in Fahrenheit shows 0 in Celsius
- Typing 33 in Fahrenheit must keep showing 33 in that box - the box you type in must never change under your fingers
- One reusable TemperatureInput component used twice
import { useState } from "react";
function CelsiusInput() {
const [celsius, setCelsius] = useState("");
return <label>Celsius <input value={celsius} onChange={(e) => setCelsius(e.target.value)} /></label>;
}
function FahrenheitInput() {
const [fahrenheit, setFahrenheit] = useState("");
return <label>Fahrenheit <input value={fahrenheit} onChange={(e) => setFahrenheit(e.target.value)} /></label>;
}
export default function Converter() {
return (
<div>
<CelsiusInput />
<FahrenheitInput />
</div>
);
}
// type 100 in Celsius -> C="100" F="" (not updated)Show one solutionHide solution
import { useState } from "react";
const toF = (c) => String(Math.round((Number(c) * 9) / 5 + 32));
const toC = (f) => String(Math.round(((Number(f) - 32) * 5) / 9));
function TemperatureInput({ label, value, onValueChange }) {
return <label>{label} <input value={value} onChange={(e) => onValueChange(e.target.value)} /></label>;
}
export default function Converter() {
// ONE source of truth in the parent: what the user typed, and in which box
const [temp, setTemp] = useState({ value: "", scale: "c" });
// the other box is derived, never stored
const celsius = temp.scale === "c" || temp.value === "" ? temp.value : toC(temp.value);
const fahrenheit = temp.scale === "f" || temp.value === "" ? temp.value : toF(temp.value);
return (
<div>
<TemperatureInput label="Celsius" value={celsius}
onValueChange={(value) => setTemp({ value, scale: "c" })} />
<TemperatureInput label="Fahrenheit" value={fahrenheit}
onValueChange={(value) => setTemp({ value, scale: "f" })} />
</div>
);
}
// Why store the scale? A first attempt stored only celsius and calculated
// Fahrenheit from it. Typing 33 in Fahrenheit then saved celsius = 1
// (rounded), and the Fahrenheit box changed to 34 while the user typed.
// Storing exactly what the user typed, and deriving the other box, fixes it.Key points
- When two components need the same state, move it to their closest common parent.
- Three steps: remove it from the child, add it to the parent, pass the value and a callback down.
- Value down, changes up: children get props and call functions; the owner decides.
- Lift only to the closest common parent - not higher.
- Everything below the owner re-renders on each change: 6 renders at SearchPage, 54 at App.
- Fixes when it climbs too high: move it back down (6), memo the unrelated parts (8), or a store (4).
- Store what the user typed and derive the rest - two stored values can disagree.
Quick check before you move on
Interview questions
What is lifting state up in React?
Moving shared state to the closest common ancestor of the components that use it. The ancestor owns the state and passes the value down as props, plus callbacks so children can request changes - keeping one source of truth and one-way data flow.
What is inverse data flow?
Children cannot change their props, so the parent passes them functions. A child calls the function with new data, and the parent updates its state, which flows back down. Data still moves one way; requests for change travel up through callbacks.
When does lifting state up stop scaling?
When the common ancestor is high in the tree: every change re-renders a large subtree, values and callbacks are drilled through many layers, and the top component collects unrelated state. Then consider memoisation, Context, or a store.
How would you reduce re-renders caused by state at the top of the tree?
First move it down to the closest common parent if possible. Otherwise memoise the subtrees that do not depend on it, or move the state to a store so only subscribed components re-render.
How do you keep two inputs that depend on each other in sync?
Lift a single source of truth to their parent - ideally exactly what the user typed and which field they typed in - and derive the other value during render rather than storing it.
Quiz
- 1.
Where should the state live if SearchBox and CourseList are both inside SearchPage?
- 2.
Renders for 2 key presses: SearchPage owns it, App owns it, App + memo, Redux store?
- 3.
Why did memo skip Header, Dashboard and Footer?
- 4.
In the converter, why was storing only celsius not enough?
- 5.
Is lifted state global state?
Comments
Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.
Loading comments...
AI
System Design
Backend
- GraphQL8 modules · 69 lessons planned
- Core Python13 modules · 75 lessons planned
- FastAPI5 sections · 20 lessons
- Node.js14 modules · 206 lessons planned
- Node.js Performance7 chapters · 36 topics
- Event Loop Lifecycle6 phases · 3 scenarios
- Docker & Containerization11 modules · 144 lessons planned
- AWS for Developers14 modules · 219 lessons planned
- CI/CD & DevOps Automation10 modules · 134 lessons planned