Lifting State & Composition
Lifting state up
Section titled “Lifting state up”When two components need to share the same state, that state cannot live in either one — it must move up to their closest common ancestor, which then passes it down as props. This is lifting state up, and it’s the default answer to “these two components need to stay in sync.”
// The parent owns the state; both children read/update it through props.function TemperatureApp() { const [celsius, setCelsius] = useState(0); return ( <> <CelsiusInput value={celsius} onChange={setCelsius} /> <FahrenheitLabel celsius={celsius} /> </> );}The state has a single source of truth (the parent), and data flows one way: down as props, back up as callbacks. Two inputs can never disagree because there is only one value.
The prop-drilling problem
Section titled “The prop-drilling problem”Lifting works beautifully until the ancestor is far above the consumer. Then the props have to thread through every intermediate component that doesn’t care about them:
// theme is only used by Button, but every layer must pass it through.<App theme={theme}> <Page theme={theme}> <Sidebar theme={theme}> <Button theme={theme} /> {/* finally used here */} </Sidebar> </Page></App>Every intermediate component gains a prop it only forwards. This is prop drilling — noisy, and a refactor headache. The instinct is to jump straight to Context, but there’s a simpler fix to try first.
Composition: pass JSX as props
Section titled “Composition: pass JSX as props”Before Context, reach for composition. Instead of drilling a value through a component, pass the already-rendered JSX in as children (or a named prop), so the intermediate component never sees the value at all.
// Sidebar doesn't know about `theme` — it just renders whatever children it's given.function Sidebar({ children }) { return <aside className="sidebar">{children}</aside>;}
// The Button is created where `theme` is in scope, then handed to Sidebar as content.<Page> <Sidebar> <Button theme={theme} /> {/* theme is bound here, not drilled */} </Sidebar></Page>flowchart TB
subgraph drill["Prop drilling"]
a1["App theme"] --> p1["Page theme"] --> s1["Sidebar theme"] --> b1["Button uses theme"]
end
subgraph comp["Composition"]
a2["App: renders Button here"] --> s2["Sidebar: renders children"]
a2 -.passes JSX.-> s2
end You can also pass JSX through named props (<Layout sidebar={<Nav />} main={<Feed />} />). The intermediate components become generic containers (“render whatever you’re given”), which is more reusable and removes the drilling.
Proof: children as a prop also avoids re-renders, not just drilling
Section titled “Proof: children as a prop also avoids re-renders, not just drilling”Composition has a second, less obvious benefit: it’s a performance optimization, without memo anywhere. When a component holds fast-changing state and renders {children}, React does not need to re-render the children subtree on every state change — because the children element was created by whoever called this component, and that caller’s own JSX didn’t change.
import { useState, type ReactNode } from 'react';
function ExpensiveChild({ onRender }: { onRender: () => void }) { onRender(); return <span>child</span>;}
// The ticking state lives INSIDE FastTimer. `children` is whatever its// caller passed in — FastTimer never re-creates it.function FastTimer({ children }: { children: ReactNode }) { const [tick, setTick] = useState(0); return ( <div> <span data-testid="tick">{tick}</span> <button onClick={() => setTick((t) => t + 1)}>tick</button> {children} </div> );}
export function ComposedApp({ onChildRender }: { onChildRender: () => void }) { return ( <FastTimer> <ExpensiveChild onRender={onChildRender} /> </FastTimer> );}import { render, screen, act, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';import { ComposedApp } from '../components/ChildrenBail';
afterEach(cleanup);
test('children passed as props do not re-render when the parent that renders them ticks', () => { let renders = 0; render(<ComposedApp onChildRender={() => renders++} />); const baseline = renders;
act(() => { screen.getByRole('button').click(); }); act(() => { screen.getByRole('button').click(); });
// FastTimer re-rendered twice (tick went 0 -> 1 -> 2), but ExpensiveChild — // passed in as `children` — rendered zero additional times. expect(renders - baseline).toBe(0); expect(screen.getByTestId('tick').textContent).toBe('2');});✓ children passed as props do not re-render when the parent that renders them ticks
Test Files 1 passed (1) Tests 1 passed (1)FastTimer re-rendered on every click (its own tick state changed), but ExpensiveChild did not re-render even once — no memo involved. ComposedApp created the <ExpensiveChild /> element exactly once and handed it to FastTimer as children; FastTimer’s own re-renders don’t touch that element at all, so React can skip re-rendering the subtree it represents. This is why “lift state down into the smallest component that needs it, and pass everything else through as children” is both the composition fix for prop drilling and a free performance win.
Controlled vs uncontrolled inputs
Section titled “Controlled vs uncontrolled inputs”A form input is controlled when its value comes from React state (value={state} plus onChange) — React owns the value, full stop. It’s uncontrolled when the DOM owns it (defaultValue, read via a ref when needed). Both are legitimate; the rule that actually matters is: don’t switch between them for the same input across its lifetime.
Proof: the exact warning when an input flips from uncontrolled to controlled
Section titled “Proof: the exact warning when an input flips from uncontrolled to controlled”export function FlippingInput({ startControlled }: { startControlled: boolean }) { // ❌ `value` starts as `undefined` (uncontrolled), then becomes a string // (controlled) once `startControlled` flips — the exact trap. const value = startControlled ? 'hello' : undefined; return <input value={value} onChange={() => {}} />;}import { render, act, cleanup } from '@testing-library/react';import { afterEach, expect, test, vi } from 'vitest';import { FlippingInput } from '../components/ControlledWarning';
afterEach(cleanup);
test('flipping value from undefined to a string logs the real React warning', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); const { rerender } = render(<FlippingInput startControlled={false} />);
act(() => { rerender(<FlippingInput startControlled={true} />); });
const messages = spy.mock.calls.map((c) => String(c[0])); expect(messages.some((m) => m.includes('changing an uncontrolled input to be controlled'))).toBe(true);
spy.mockRestore();});✓ flipping value from undefined to a string logs the real React warning
Test Files 1 passed (1) Tests 1 passed (1)The verbatim message: "A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://react.dev/link/controlled-components". The usual cause is state initialized from something that can be null/undefined at first (an API response, an optional prop) — the <input> reference fix is simple: coerce it to a string from the start, value={someValue ?? ''}.
Derived state instead of a syncing effect
Section titled “Derived state instead of a syncing effect”A frequent anti-pattern: mirroring a prop into state with useEffect, when the value could simply be computed during render. The effect version does the same work, plus an extra render, plus a frame where the state is briefly stale.
import { useState, useEffect } from 'react';
// ❌ Anti-pattern: mirrors props into state via an effect.export function SyncedEffectFullName({ firstName, lastName, onRender,}: { firstName: string; lastName: string; onRender: () => void;}) { const [fullName, setFullName] = useState(`${firstName} ${lastName}`); useEffect(() => { setFullName(`${firstName} ${lastName}`); }, [firstName, lastName]); onRender(); return <span data-testid="name">{fullName}</span>;}
// ✅ Derived during render — no effect, no extra render, never stale.export function DerivedFullName({ firstName, lastName, onRender,}: { firstName: string; lastName: string; onRender: () => void;}) { const fullName = `${firstName} ${lastName}`; onRender(); return <span data-testid="name">{fullName}</span>;}import { render, screen, act, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';import { SyncedEffectFullName, DerivedFullName } from '../components/DerivedVsEffect';
afterEach(cleanup);
test('the effect-synced version takes one extra render to reach the same result', () => { let renders = 0; const r = render(<SyncedEffectFullName firstName="Ada" lastName="L" onRender={() => renders++} />); act(() => { r.rerender(<SyncedEffectFullName firstName="Grace" lastName="H" onRender={() => renders++} />); }); // mount (1) + prop re-render (1) + effect's own setState re-render (1) = 3. expect(renders).toBe(3); expect(screen.getByTestId('name').textContent).toBe('Grace H');});
test('the derived version reaches the same result with no extra render', () => { let renders = 0; const r = render(<DerivedFullName firstName="Ada" lastName="L" onRender={() => renders++} />); act(() => { r.rerender(<DerivedFullName firstName="Grace" lastName="H" onRender={() => renders++} />); }); // mount (1) + prop re-render (1) = 2 — no effect, nothing to re-sync. expect(renders).toBe(2); expect(screen.getByTestId('name').textContent).toBe('Grace H');});✓ the effect-synced version takes one extra render to reach the same result✓ the derived version reaches the same result with no extra render
Test Files 1 passed (1) Tests 2 passed (2)Both end up showing “Grace H” — but the effect version got there in 3 renders instead of 2, and for one of those renders the DOM briefly held the old derived value ("Ada L") before the effect corrected it. Whenever a value can be computed directly from props/state you already have, compute it during render; reach for an effect only to synchronize with something genuinely outside React (the DOM, a subscription, a network request).
key as a reset switch
Section titled “key as a reset switch”A list’s key gives React an identity to diff against — covered in the rendering & reconciliation lesson. The same mechanism has a second, deliberate use here: changing an element’s key forces React to throw away the old instance and mount a brand-new one, resetting all of its local state. This is the idiomatic fix for “this component’s internal state needs to fully reset when some identifying value changes” — cleaner than manually resetting every piece of state yourself.
import { useState } from 'react';
function Field({ onMount }: { onMount: () => void }) { const [text, setText] = useState(''); useState(() => { onMount(); return null; }); return <input data-testid="field" value={text} onChange={(e) => setText(e.target.value)} />;}
// ❌ No key — the same Field instance persists across a userId change,// keeping whatever the user had typed for the PREVIOUS user.export function WithoutKey({ onMount }: { userId: string; onMount: () => void }) { return <Field onMount={onMount} />;}
// ✅ Keyed by userId — a new user means a fresh Field instance, reset state.export function WithKey({ userId, onMount }: { userId: string; onMount: () => void }) { return <Field key={userId} onMount={onMount} />;}import { render, screen, fireEvent, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';import { WithoutKey, WithKey } from '../components/KeyReset';
afterEach(cleanup);
test('without a key, typed text survives a prop change on the same instance', () => { let mounts = 0; const r = render(<WithoutKey userId="a" onMount={() => mounts++} />); fireEvent.change(screen.getByTestId('field'), { target: { value: 'typed' } });
r.rerender(<WithoutKey userId="b" onMount={() => mounts++} />);
expect(mounts).toBe(1); // never remounted expect((screen.getByTestId('field') as HTMLInputElement).value).toBe('typed');});
test('a key change remounts the component and resets its state', () => { let mounts = 0; const r = render(<WithKey userId="a" onMount={() => mounts++} />); fireEvent.change(screen.getByTestId('field'), { target: { value: 'typed' } });
r.rerender(<WithKey userId="b" onMount={() => mounts++} />);
expect(mounts).toBe(2); // remounted once expect((screen.getByTestId('field') as HTMLInputElement).value).toBe(''); // reset});✓ without a key, typed text survives a prop change on the same instance✓ a key change remounts the component and resets its state
Test Files 1 passed (1) Tests 2 passed (2)This is a different concern from a list’s key: there, key is about matching elements across renders so React doesn’t confuse one item for another. Here, key={userId} is a deliberate signal that this specific value defines the component’s identity — change it, and you want a fresh start, not an update.
Mental Model
Section titled “Mental Model”Composition and lifting are two ends of the same lever: lift state up when siblings must agree on one value; push rendered JSX down (as children/props) when a component in between doesn’t need to know the value at all. Neither is about where data is stored — lifting is about ownership, composition is about who gets to see a value versus who just forwards a slot for content created elsewhere. A component that only renders children is, from a data-flow perspective, invisible — and from a rendering perspective, its re-renders are cheap precisely because that invisible slot doesn’t change just because the wrapper’s own state does.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Lifting state up | Single source of truth, guaranteed sync | Common ancestor grows more responsibilities |
Composition (children/JSX props) | Fixes prop drilling and skips re-renders of the passed-in subtree | Intermediate components must be designed generically from the start |
| Controlled input | Value always matches React state; easy to validate/transform on every keystroke | Must always supply a defined value — never flip to/from undefined |
| Uncontrolled input | Less re-rendering; simpler for “just read it on submit” forms | Harder to validate live, or drive the field’s value programmatically |
| Derived state (computed during render) | No extra render, never stale, less code | Only works when the value is a pure function of existing props/state |
key as a reset switch | One line resets all of a component’s internal state | Its intent (reset vs list identity) must be documented — both look identical in JSX |
Common Misconceptions
Section titled “Common Misconceptions”- “Composition only helps avoid prop drilling.” It also skips re-rendering the passed-in subtree when the wrapping component’s own state changes — proven above, with no
memoanywhere. - “An input can start uncontrolled and become controlled once data loads.” That’s exactly the trap React warns about — proven above with the verbatim message. Initialize the state as a defined value (
'', notundefined) from the start instead. - “Mirroring a prop into state with
useEffectis just a slightly less efficient way to keep them in sync.” It’s not just less efficient — proven above, it takes an extra render to reach the same result, and briefly renders a stale value in between. - “
keyis only for lists.”keyon any single element is a valid, idiomatic way to force a full remount-and-reset — proven above — completely independent of whether that element is inside a list.
Reading AI-generated code
Section titled “Reading AI-generated code”Before trusting generated components around state placement, check for these:
- Is a component defined inline, as JSX, deep inside another component that re-renders often — instead of being passed in as
children/a prop from further up? That’s drilling by construction, and it also means that subtree can never skip a re-render the way composedchildrencan. - Does an
<input>’svaluecome from something that could benull/undefinedbefore data loads (an API response, an optional field)? That’s the controlled/uncontrolled flip proven above — default it to a defined value immediately. - Is there a
useEffectwhose only job issetSomething(propOrOtherState)with no other side effect? That’s almost always derivable during render instead — proven above to cost an extra render and a stale frame. - Is
key={index}used on a form or stateful child when the intent is actually “reset when the underlying record changes,” rather than the record’s own stable id? The index doesn’t identify anything meaningful and can reset (or fail to reset) state incorrectly as items are added/removed/reordered. - Does a component reach for Context (or prop drilling) for a value that’s only needed by one or two components a couple of layers down, when passing rendered JSX as
childrenwould remove the middle layer’s need to know about it at all?
🐛 Spot the bug
function UserProfilePage({ userId }) {const user = useUser(userId);return <ProfileForm initialName={user.name} />;}
function ProfileForm({ initialName }) {const [name, setName] = useState(initialName);return <input value={name} onChange={(e) => setName(e.target.value)} />;}Show the answer
When userId changes (navigating from one user’s profile to another’s), ProfileForm is the same component instance — React only re-renders it with a new initialName prop, it does not remount it. But useState(initialName) only uses that argument on the very first render; every subsequent render keeps whatever the previous user’s edits left in name, silently showing stale text from user A while editing user B’s profile. The fix is exactly the key pattern proven above: <ProfileForm key={userId} initialName={user.name} /> — a new userId now forces a fresh ProfileForm instance, so useState(initialName) genuinely re-initializes from the new user’s data instead of carrying over the old one.