The problem
Two components need the same value. A search box holds the query; a list needs it to filter. Neither can reach into the other — props only go down.
function Search() {
const [query, setQuery] = React.useState(""); // trapped in here
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
function Panel() {
return (
<>
<Search />
<Results /> {/* has no way of knowing what was typed */}
</>
);
}
The move
Lift the state to the closest common parent of everything that needs it. The children become simple: one takes a value and reports changes, the other takes data and draws it. Neither remembers anything.
function Panel() {
const [query, setQuery] = React.useState("");
const shown = doors.filter((d) => d.name.toLowerCase().includes(query.toLowerCase()));
return (
<>
<Search value={query} onChange={setQuery} />
<Results doors={shown} />
<p>{shown.length} of {doors.length}</p>
</>
);
}
function Search({ value, onChange }) {
return <input value={value} onChange={(e) => onChange(e.target.value)} />;
}
What the child sends up
A child that hands up a clean value can be reused anywhere. A child that hands up a DOM event ties every parent to the markup inside it.
// the parent now has to know about DOM events
<Search onChange={(e) => setQuery(e.target.value)} />
// better: the child translates, the parent gets a value
function Search({ value, onChange }) {
return <input value={value} onChange={(e) => onChange(e.target.value)} />;
}
<Search value={query} onChange={setQuery} />
Controlled and uncontrolled components
- Uncontrolled is simpler and right when nothing outside cares.
- Controlled is right the moment something outside does: two panels where only one may be open, a form that resets from a button elsewhere, a value that must persist.
- Many components offer both —
valueif you pass it, an internal fallback if you do not. Write the uncontrolled one first, and lift only when you need to.
// uncontrolled: it owns its open state, the parent cannot influence it
function Panel({ title, children }) {
const [open, setOpen] = React.useState(false);
…
}
// controlled: the parent owns it — now two panels can be exclusive
function Panel({ title, open, onToggle, children }) { … }
One owner, always
// the parent resets the editor by changing its identity
<Editor key={selectedId} door={selected} />
const [name, setName] = useState(props.name) reads the prop once. When the parent changes it, the copy goes stale and the two disagree — the bug arrives as "it shows the old value until I reload".
How far up
- Only as far as the closest parent that contains everything that needs it. Higher than that and every render repaints half the app.
- If the common parent is five levels up and the middle three do nothing but pass props along, that is prop drilling — a sign to look at context, or at composition (
children) which often removes the need entirely. - Server data — a list fetched from an API — is not really component state at all. It belongs in a cache like TanStack Query, which is a different lesson.
Composition removes the drilling
The element is created where the data already is. Everything in between just renders children, and never sees the prop.
// Layout no longer needs to know about user at all
<Layout sidebar={<Profile user={user} />}>
<Doors user={user} />
</Layout>