Skip to content
Get the beta

Search the docs

Start with the beta
Installation
Build your first page
Thinking in Turtlekit
Apps, websites and embeds
Components or native elements
Colour
Typography
Layout
Spacing, icons and shape
Components
Root
Layout
Typography
Code block
Button
Icon button
Button group
Call to action
Toolbar
Field
Text field
Text area
Select
Combobox
Date input
Save bar
Newsletter signup
File upload
Calendar
One-time code
Checkbox
Radio group
Switch
Segmented control
Toggle
Slider
Badge
Card
Data list
Item
Icon tile
Num
Time
Metric
Accordion
FAQ
Avatar
Media
Scroll area
Resizable
Carousel
Image slider
Logo cloud
Testimonials
Table
Data table
Chart
Site header
Site footer
Tabs
Page header
Breadcrumb
Pagination
Sidebar
Navigation menu
Callout
Error summary
Toast
Empty state
Skeleton
Progress
Spinner
Tooltip
Dialog
Popover
Hover card
Dropdown menu
Context menu
Menubar
Command
Elements area
Visually hidden
Error boundary
Design rules
Staying within the kit
Layout
Icons
Images and media
Typography
Readability
Colour
Composition
Accessibility
Elements
Motion
Interaction
AI pitfalls
Pricing
React design patterns
Single responsibility in React components
Presentational and container components in React
Children, function children and render props in React
The variant pattern in React
Compound components in React
Higher-order components in React
State colocation in React
Lifting state up in React
Deriving state during render
One status instead of many booleans
Adjusting state when a prop changes
Reading context with use() in React 19
Context selectors in React
Custom hooks and hook composition in React
One effect per concern in React
The latest ref pattern in React
Effect Events with useEffectEvent in React
Detecting the first render in React
Exposing methods with useImperativeHandle in React
Debouncing input in React
useDeferredValue in React
Loading components on demand in React
Virtualising long lists in React
Guard clauses and early returns in React
Skeleton placeholders in React
Rendering partial data in React
Empty states in React
Error boundaries in React
Reading async data with use() and Suspense in React
useTransition and Actions in React
Optimistic UI updates with useOptimistic
useActionState and form Actions in React
useSyncExternalStore in React
Ref as a prop and ref cleanup in React 19
Accessible ids with useId in React
Portals in React
Roving focus and keyboard navigation in React
Controlled and uncontrolled components in React
The state reducer and prop getters in React
Headless components and the provider pattern in React
The polymorphic as prop in React

Appearance

These change every page and preview on this site.

Lifting state up in React

Turtlekit Editorial Team

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:

The two copies, through the customer’s three clicks
MomentThe picker’s copyThe page’s copy
Business, yearly chosenBusiness, yearlyBusiness, yearly
“Start over” pressedTeam, monthlyBusiness, yearly
“Upgrade” pressedTeam, monthlyBusiness, 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.

UpgradePageorderPlanPickerorder + onOrderChangeOrderSummaryorder
One copy of the order, held by the closest parent of everyone who reads it. Values go down as props; changes come back up as calls.

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.

State colocation in React

When only one component reads the value, and it should live there instead.

Deriving state during render

When the parent is tempted to store the price as well as the order.

One status instead of many booleans

When the upgrade’s own progress needs a state of its own.

Adjusting state when a prop changes

When a lifted value changes and a child’s own state has to follow.