The ticket said the upgrade page had charged the wrong plan. The customer had picked Business, billed yearly, then changed their mind, pressed “Start over” and upgraded with the form showing Team, monthly. Their card was charged €790 for a year of Business, which is what the summary beside the form had said all along.
Nobody had typed a wrong price. The plan picker kept its own copy of the order and told the page about each change. “Start over”, added months later, reset the picker’s copy and told no one. The form and the summary were each right, about two different orders.
Lifting state up is how two components come to agree: move the state to their closest common parent and pass it down as props. With one copy of the order, the picker, the summary and the charge can’t disagree, because there is nothing left for them to disagree about.
Two copies of one order
Here is the picker as it shipped. Look for the line every change has to remember:
// Avoid: two copies of the order, kept in step by hand
function PlanPicker({
onOrderChange,
}: {
onOrderChange: (order: Order) => void;
}) {
const [order, setOrder] = useState(DEFAULT_ORDER);
function change(next: Order) {
setOrder(next);
onOrderChange(next); // every change has to remember this line
}
return (
<>
<PlanChoice
value={order.plan}
onChange={(plan) => change({ ...order, plan })}
/>
<CycleChoice
value={order.cycle}
onChange={(cycle) => change({ ...order, cycle })}
/>
{/* Added later: resets the picker's copy, not the page's */}
<button type="button" onClick={() => setOrder(DEFAULT_ORDER)}>
Start over
</button>
</>
);
}change updates both copies, so every control wired through it stays in step. The reset button calls setOrder directly, and the page’s copy, which feeds the summary and the charge, keeps the old order:
| Moment | The picker’s copy | The page’s copy |
|---|---|---|
| Business, yearly chosen | Business, yearly | Business, yearly |
| “Start over” pressed | Team, monthly | Business, yearly |
| “Upgrade” pressed | Team, monthly | Business, yearly: €790 charged |
The code is correct for as long as everyone who touches the picker remembers that line. That is the trouble with two copies: the bug is never in the code that exists, only in the next handler someone writes.
One owner, values down, changes up
When two components must agree on a value, neither of them should own it. The owner is their closest common parent. It holds the one copy and passes it down; the children show what they receive and ask for changes through callbacks.
react.dev puts the move in three steps: remove the state from the children, pass the data from the common parent, then add the state to that parent and pass it down with the handlers that change it.
Lifting the order to the page
The fix touches three files: the picker gives up its copy, the summary computes its price, and the page takes the order.
A picker with nothing to remember
The picker becomes controlled. It shows the order it is given and reports each change, and “Start over” is one more change:
interface PlanPickerProps {
order: Order;
onOrderChange: (order: Order) => void;
}
export function PlanPicker({
order,
onOrderChange,
}: PlanPickerProps) {
return (
<>
<PlanChoice
value={order.plan}
onChange={(plan) => onOrderChange({ ...order, plan })}
/>
<CycleChoice
value={order.cycle}
onChange={(cycle) => onOrderChange({ ...order, cycle })}
/>
<button
type="button"
onClick={() => onOrderChange(DEFAULT_ORDER)}
>
Start over
</button>
</>
);
}There is no second copy left to forget, so the reset can’t drift from the summary, whoever writes the next control.
A summary that computes, not stores
The summary receives the order and works out the price during render. Lift the order, never the price: the price is a formula over the order, and storing it would make a third copy.
// The summary beside the picker: it reads the order and computes the
// price.
import { useId } from "react";
import { PLANS, euros, priceOf } from "./order";
import type { Order } from "./order";
export function OrderSummary({ order }: { order: Order }) {
const id = useId();
const { amount, per, saving } = priceOf(order);
return (
<section aria-labelledby={id}>
<h2 id={id}>Summary</h2>
{/* Polite: the price is read when people stop changing it. */}
<p role="status">
{PLANS[order.plan].name}, {euros(amount)} a {per}
{saving ? `, saving ${euros(saving)}` : ""}
</p>
</section>
);
}The page that owns the order
The page holds the one copy and hands it to both children:
"use client";
// The upgrade page owns the order: the picker changes it, the summary
// reads it, and the upgrade sends exactly what both of them show.
import { useState } from "react";
import { OrderSummary } from "./order-summary";
import { PlanPicker } from "./plan-picker";
import { DEFAULT_ORDER } from "./order";
import type { Order } from "./order";
interface UpgradePageProps {
onUpgrade: (order: Order) => void;
}
export function UpgradePage({ onUpgrade }: UpgradePageProps) {
const [order, setOrder] = useState<Order>(DEFAULT_ORDER);
return (
<main>
<h1>Upgrade your workspace</h1>
<form
onSubmit={(event) => {
event.preventDefault();
onUpgrade(order);
}}
>
<PlanPicker order={order} onOrderChange={setOrder} />
<OrderSummary order={order} />
<button type="submit">Upgrade</button>
</form>
</main>
);
}Choose Business and yearly billing, and the summary reads “Business, €790 a year, saving €158”. Press “Start over” and the picker and the summary return to Team, monthly together. “Upgrade” sends the order both of them show.
What the owner takes on
An owner gains a job, and the details of that job decide whether the lift was worth it.
The closest common parent, and no higher
The order belongs to the upgrade page, not to the app shell or a global store. Lifting further than the closest common parent recreates the problem colocation solves: an owner that holds a value most of its tree never reads, and re-renders all of it on every change.
Controlled, uncontrolled, or both
A component is controlled when its important values come from props, and uncontrolled when it keeps them in its own state. Uncontrolled components are easier to drop in; controlled ones can be coordinated. A shared component often offers both: value and onValueChange for a parent that owns the value, defaultValue for one that doesn’t.
Each instance picks one for its whole life. An input whose value starts as undefined and later becomes a string has switched from uncontrolled to controlled, and React warns about it.
A contract the next owner can reuse
Once controlled, the picker works under any owner. The sign-up flow can render it with its own state, and a settings page can feed it from the server. The props are the contract, so keep their shape plain: the current value, and a callback that receives the next whole value. A callback that passes a DOM event instead makes every owner learn how the picker is built inside.
The cost of one more render
Every change now re-renders the page, the picker and the summary. Here that is a handful of elements. When the owner’s tree is large, move the state into a small component that wraps only its readers, and leave the rest of the layout outside it. The React Compiler skips children whose props haven’t changed, which makes a lift cheaper still.
A summary that speaks
The summary’s price line is a status region, so a screen reader reads the new price politely after each choice. Keep that line short, because it is read on every change. Each group of radios has a legend, so “Yearly, two months free” is heard as an answer to “Billing”.
Testing at the owner
Lifted state is a promise about several components at once, so the test renders their owner and checks that they agree:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { UpgradePage } from "./upgrade-page";
test("the summary follows every choice in the picker", async () => {
const user = userEvent.setup();
render(<UpgradePage onUpgrade={() => {}} />);
expect(screen.getByRole("status")).toHaveTextContent(
"Team, €29 a month",
);
await user.click(
screen.getByRole("radio", { name: "Business, €79 a month" }),
);
await user.click(
screen.getByRole("radio", { name: "Yearly, two months free" }),
);
expect(screen.getByRole("status")).toHaveTextContent(
"Business, €790 a year, saving €158",
);
});
test("starting over resets picker, summary and order", async () => {
const user = userEvent.setup();
const onUpgrade = vi.fn();
render(<UpgradePage onUpgrade={onUpgrade} />);
await user.click(
screen.getByRole("radio", { name: "Business, €79 a month" }),
);
await user.click(
screen.getByRole("radio", { name: "Yearly, two months free" }),
);
await user.click(
screen.getByRole("button", { name: "Start over" }),
);
expect(
screen.getByRole("radio", { name: "Team, €29 a month" }),
).toBeChecked();
expect(screen.getByRole("status")).toHaveTextContent(
"Team, €29 a month",
);
await user.click(screen.getByRole("button", { name: "Upgrade" }));
expect(onUpgrade).toHaveBeenCalledWith({
plan: "team",
cycle: "monthly",
});
});Both pass. Give the picker its own copy back and the second test fails on the summary, which still reads Business.
On the server, and in the address
The owner holds state, so it is a Client Component. Keep it small: a Server Component can render the layout around it and pass onUpgrade down, as long as onUpgrade is a Server Function, the one kind of function that can cross to the client. Only the page and its children then ship to the browser.
Sometimes the right owner is the address itself. With ?plan=business&cycle=yearly in the URL, the order survives a reload, can be shared with a finance team, and arrives with the server’s first HTML. The router becomes the common parent, and the picker stays controlled.
React 19’s form Actions
Lifting state works the same in React 18. React 19’s form Actions fit it well: a <form action> receives the checked radios as FormData, the same values the summary shows, because both come from the one copy of the order.
When lifting is the wrong move
Lift only for a second reader. A value that one component reads belongs in that component, as State colocation in React argues.
When the readers are many and far apart, passing props through every level in between turns the middle of the tree into a relay; that is where context fits, as Reading context with use() shows. And when an owner collects so much state that it is hard to read, split it, or move its logic into a custom hook, before lifting anything else into it.
How Turtlekit’s choices and dialogs take their state from you
RadioCards and RadioGroup are uncontrolled with defaultValue and controlled with value and onValueChange. The upgrade page passes its order to both:
// The upgrade page with Turtlekit: RadioCards and RadioGroup are
// controlled by the page's order through value and onValueChange.
import { useState } from "react";
import {
Button,
RadioCards,
RadioGroup,
Stack,
Text,
} from "turtlekit";
import {
DEFAULT_ORDER,
PLANS,
PLAN_IDS,
euros,
priceOf,
} from "./order";
import type { Order } from "./order";
const PLAN_OPTIONS = PLAN_IDS.map((id) => ({
value: id,
label: PLANS[id].name,
hint: `${euros(PLANS[id].monthly)} a month`,
}));
const CYCLE_OPTIONS = [
{ value: "monthly", label: "Monthly" },
{ value: "yearly", label: "Yearly", hint: "Two months free" },
];
export function UpgradePlan({
onUpgrade,
}: {
onUpgrade: (order: Order) => void;
}) {
const [order, setOrder] = useState<Order>(DEFAULT_ORDER);
const { amount, per, saving } = priceOf(order);
return (
<Stack gap="group">
<RadioCards
label="Plan"
columns="3"
options={PLAN_OPTIONS}
value={order.plan}
onValueChange={(value) => {
const plan = PLAN_IDS.find((id) => id === value);
if (plan) setOrder({ ...order, plan });
}}
/>
<RadioGroup
label="Billing"
options={CYCLE_OPTIONS}
value={order.cycle}
onValueChange={(value) =>
setOrder({
...order,
cycle: value === "yearly" ? "yearly" : "monthly",
})
}
/>
<Text as="p" role="status">
{PLANS[order.plan].name}, {euros(amount)} a {per}
{saving ? `, saving ${euros(saving)}` : ""}
</Text>
<Button onClick={() => onUpgrade(order)}>Upgrade</Button>
</Stack>
);
}Each option’s hint is part of its description, not its name, so the plan is still announced as “Team”. Dialog takes the same shape for whether it is open: the parent owns open, and the dialog only reports onClose.
Built on this pattern in Turtlekit: Data table, Dialog, File upload, Radio group and Resizable.
When the copies come back
These three account for most of the trouble once state has been lifted.
A radio won’t change when it is clicked
It is controlled and nothing updates its value. Check that onChange is passed and that the owner calls its setter; React warns when a field has checked or value and no handler.
A component is changing an uncontrolled input to be controlled
The value started as undefined. Give the owner’s state a real initial value, such as an empty string or the default order.
The summary shows the change before last
Something reads the state straight after setting it and gets the old snapshot. Build the next value first, pass it to the setter and use that same value wherever it is needed next.
Agreement needs an owner
Two components that keep their own copies of a fact are a promise that every future change will update both. Promises like that are kept for a while. Lifting the state replaces the promise with a structure: one owner, one copy, and children that can only show what they are given or ask for a change. It costs a few props, and buys the one property a checkout can’t do without, that what people see is what they pay for.