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.

State colocation in React

Turtlekit Editorial Team

Support agents at a billing company write a lot of notes. On the customer page, the note box sits at the bottom, under the contact details and two years of invoices, and on the largest accounts it had started to fall a few characters behind the typing. Nobody had touched the note box in months.

The profiler showed why in a single recording. Every keystroke re-rendered the whole page, the invoice list included, because the draft lived at the top, in the page component. It had been put there in the first week, so that all of the page’s state would sit in one place.

The fix has a name, state colocation: keep each piece of state in the lowest component that reads it. The draft belongs to the note box, and once it lives there, a keystroke re-renders the note box and nothing else.

A page that holds what it never reads

Here is the page as it stood. Watch what it does with noteDraft:

// Avoid: the page holds state that only one child reads
export function CustomerPage(props: {
  customer: Customer;
  saveNote: SaveNote;
}) {
  const { customer, saveNote } = props;
  const [noteDraft, setNoteDraft] = useState("");
  const [showAllInvoices, setShowAllInvoices] = useState(false);

  return (
    <main>
      <h1>{customer.name}</h1>
      <InvoiceHistory
        invoices={customer.invoices}
        showAll={showAllInvoices}
        onShowAllChange={setShowAllInvoices}
      />
      <NoteComposer
        draft={noteDraft}
        onDraftChange={setNoteDraft}
        onSave={async () => {
          await saveNote(customer.id, noteDraft);
          setNoteDraft("");
        }}
      />
    </main>
  );
}

It does nothing with it. The page stores the draft, hands it down with its setter, and reads it once inside a callback that it also hands down. showAllInvoices follows the same route: two props go into the history for a value no other component uses.

The page works, and nothing here is a bug. The cost is spread thin. Each keystroke re-renders the page and every child React can’t skip. The history’s props describe its own insides, so a change to how older invoices appear is also a change to the page. And whoever reads the page has to trace two values to learn that it never uses them.

Every piece of state has an address

Think of each value as having one right address: the lowest component that all of its readers sit inside. For the draft, that is the note box itself. For the expanded list, it is the history. The page’s own address holds what the page reads, which is the customer.

CustomerPagecustomer · saveNoteHeaderno stateHistoryshowAllNotedraft
Each value lives in the one component that reads it. A keystroke in the note re-renders Note alone; the page and its other children stay as they were.

Put state where it is read, and move it only when a second reader appears. Colocation and lifting state up are the same rule followed in two directions, which is why react.dev expects state to move down and back up while a screen is still taking shape.

Moving the state down, one piece at a time

The move takes three files: the note box, the history, and what remains of the page. Start by asking who reads each value.

Who reads each value on the customer page
ValueRead byLives in
The draftThe note boxThe note box
Older invoices shownThe historyThe history
The customerEvery partThe page’s props

The draft moves into the note box

The composer takes the two things it can’t know, the customer’s id and the function that saves, and keeps everything else:

const [draft, setDraft] = useState("");
const [status, setStatus] = useState<SaveStatus>("editing");

async function handleSubmit(event: FormEvent<HTMLFormElement>) {
  event.preventDefault();
  const note = draft.trim();
  if (!note || status === "saving") return;
  setStatus("saving");
  try {
    await saveNote(customerId, note);
    setDraft("");
    setStatus("saved");
  } catch {
    // The draft stays, so nothing typed is lost.
    setStatus("failed");
  }
}

The save’s progress is one value rather than three flags, for the reasons in One status instead of many booleans. A failed save leaves the draft in the box, so nothing typed is lost.

The toggle moves into the history

Whether the older invoices show is now decided beside the list it changes:

const [showAll, setShowAll] = useState(false);
const shown = showAll ? invoices : invoices.slice(0, RECENT);

shown is computed during render rather than stored, so it can’t disagree with the toggle.

The page that’s left

What remains is a layout with props in and nothing held:

// The customer page after the move: it lays out one customer and
// holds no state of its own. Its two interactive parts keep theirs.
import { InvoiceHistory } from "./invoice-history";
import { NoteComposer } from "./note-composer";
import type { Customer, SaveNote } from "./customer";

interface CustomerPageProps {
  customer: Customer;
  saveNote: SaveNote;
}

export function CustomerPage({
  customer,
  saveNote,
}: CustomerPageProps) {
  return (
    <main>
      <header>
        <h1>{customer.name}</h1>
        <p>{customer.email}</p>
      </header>
      <InvoiceHistory invoices={customer.invoices} />
      <NoteComposer customerId={customer.id} saveNote={saveNote} />
    </main>
  );
}

Type in the note and only NoteComposer renders. Save, and the box clears with “Note saved.” in its status line. If the save fails, the draft stays and an alert says to try again. The history opens to all seven invoices and back, and the page never hears about it.

What changes when state moves down

Moving a value is a small edit, and it reaches further than it looks.

The cheapest performance fix there is

Wrapping the history in memo would also have stopped its re-renders, by comparing its props on every keystroke and asking the page to keep them stable. Moving the state removes the work instead of skipping it. Confirm the result in the React DevTools Profiler, with “Highlight updates when components render” switched on, while typing a long note on a large account.

The React Compiler narrows the gap: it memoises the page’s JSX, so the history would be skipped even with the draft at the top. The page would still hold a value it never reads, and that cost falls on every person who reads it.

State that leaves with its component

React keeps a component’s state as long as the component renders in the same place in the tree. Colocated state therefore ends when its owner unmounts. Put the composer behind a tab, switch tabs, and the draft is gone.

There are three answers. React 19.2’s <Activity> hides a subtree while keeping its state, and cleans up its effects until it shows again. Lifting the draft to a parent that stays mounted works in any version. Saving it to sessionStorage also survives a reload, which is kind to someone who spent five minutes on a note.

Accessibility travels with the state

The composer owns its status line and its alert, so each message sits beside the action that caused it. The toggle reads aria-expanded from the state next to it. When state lives far above, attributes like these are wired through props, and wiring through props is what gets forgotten in a refactor.

Tests that describe what people see

A colocated component can be tested alone with two props. The test here still renders the page, because the page is the contract, and asserts by role:

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { CustomerPage } from "./customer-page";
import type { Customer, Invoice } from "./customer";

const INVOICES: Invoice[] = [41, 40, 39, 38, 37, 36, 35].map((n) => ({
  id: `INV-00${n}`,
  issued: `${n - 30} Oct 2026`,
  total: "€240.00",
  status: n === 41 ? "open" : "paid",
}));

const BRIGHTSIDE: Customer = {
  id: "cus_8421",
  name: "Brightside Studio",
  email: "accounts@brightside.studio",
  invoices: INVOICES,
};

test("a note saves, clears and says so", async () => {
  const user = userEvent.setup();
  const saveNote = vi.fn(() => Promise.resolve());
  render(<CustomerPage customer={BRIGHTSIDE} saveNote={saveNote} />);
  const note = screen.getByRole("textbox", { name: "Private note" });
  expect(note).toHaveAccessibleDescription("Only your team sees it.");
  await user.type(note, "Prefers invoices in Dutch");
  await user.click(screen.getByRole("button", { name: "Save note" }));
  expect(saveNote).toHaveBeenCalledWith(
    "cus_8421",
    "Prefers invoices in Dutch",
  );
  expect(note).toHaveValue("");
  expect(screen.getByRole("status")).toHaveTextContent("Note saved.");
});

test("a failed save keeps the draft, says what to do", async () => {
  const user = userEvent.setup();
  render(
    <CustomerPage
      customer={BRIGHTSIDE}
      saveNote={() => Promise.reject(new Error("Offline"))}
    />,
  );
  const note = screen.getByRole("textbox", { name: "Private note" });
  await user.type(note, "Call before the 5th");
  await user.click(screen.getByRole("button", { name: "Save note" }));
  expect(note).toHaveValue("Call before the 5th");
  expect(screen.getByRole("alert")).toHaveTextContent(
    "We couldn’t save the note. Try again.",
  );
});

test("the history shows recent invoices, then all", async () => {
  const user = userEvent.setup();
  render(
    <CustomerPage
      customer={BRIGHTSIDE}
      saveNote={() => Promise.resolve()}
    />,
  );
  const invoices = screen.getByRole("region", { name: "Invoices" });
  expect(invoices.querySelectorAll("li")).toHaveLength(5);
  const toggle = screen.getByRole("button", {
    name: "Show all 7 invoices",
  });
  expect(toggle).toHaveAttribute("aria-expanded", "false");
  await user.click(toggle);
  expect(invoices.querySelectorAll("li")).toHaveLength(7);
  expect(
    screen.getByRole("button", { name: "Show recent invoices" }),
  ).toHaveAttribute("aria-expanded", "true");
});

All three pass. Move the draft back into the page and they still pass, which is the point: colocation changes where state lives, not what people see.

A page that can stay on the server

With no state left, the page needs no "use client". It can be a Server Component that renders the customer, with only the history and the composer shipped as client code. saveNote then has to be a Server Function, the one kind of function a Server Component can pass to a Client Component. Every piece of state held at the top pulls the whole page into the browser’s bundle.

What React 19 adds

Colocation predates hooks and works the same in React 18. What is new is the room around it: Server Components reward pages that hold nothing, <Activity> keeps hidden state alive, and the Compiler forgives state held too high without making it the right place.

When state has to move up instead

Colocation is a starting point, and three things move state away from it. A second reader is the first. If the header starts showing an “Unsaved note” badge, the draft has two readers and belongs to their closest common parent, as Lifting state up in React describes.

State that should survive a reload or travel in a link, such as a filter, a sort order or the open tab, belongs in the address bar as search parameters. A value read by many distant components belongs in context, ideally one that changes rarely.

How Turtlekit’s Collapsible and Pagination keep their state

Whether a Collapsible is open is kept inside it. The page passes a summary and the content, and defaultOpen at most. Here is the history from above, with the older invoices behind one:

// The customer's invoices with Turtlekit: the recent ones in an
// ItemList and the older ones in a Collapsible, which keeps its open
// state to itself.
import { Badge, Collapsible, Item, ItemList } from "turtlekit";
import type { Invoice } from "./customer";

const STATUS = {
  paid: { tone: "success", label: "Paid" },
  open: { tone: "info", label: "Open" },
  overdue: { tone: "danger", label: "Overdue" },
} as const;

function InvoiceRows({
  invoices,
  label,
}: {
  invoices: readonly Invoice[];
  label: string;
}) {
  return (
    <ItemList label={label}>
      {invoices.map((invoice) => (
        <Item
          key={invoice.id}
          title={invoice.id}
          description={`Issued ${invoice.issued} · ${invoice.total}`}
          meta={
            <Badge tone={STATUS[invoice.status].tone}>
              {STATUS[invoice.status].label}
            </Badge>
          }
        />
      ))}
    </ItemList>
  );
}

export function InvoiceHistory({
  invoices,
}: {
  invoices: readonly Invoice[];
}) {
  const older = invoices.slice(5);
  return (
    <>
      <InvoiceRows
        invoices={invoices.slice(0, 5)}
        label="Recent invoices"
      />
      {older.length ? (
        <Collapsible summary={`Show ${older.length} older invoices`}>
          <InvoiceRows invoices={older} label="Older invoices" />
        </Collapsible>
      ) : null}
    </>
  );
}

Pagination keeps the same habit for its go-to-page field. The typed number and its error stay inside; the page hears only about a page it can go to, through hrefFor or onPageChange. Each row is an Item, whose status Badge carries its word as well as its tone.

Built on this pattern in Turtlekit: Chart, Context menu, Date input, Hover card, Menubar, One-time code, Pagination, Save bar and Toast.

When colocated state misbehaves

Three symptoms cover most of the trouble with state that lives low in the tree.

The draft disappears when people switch tabs

The composer unmounted and took its state with it. Keep it mounted with <Activity>, lift the draft to a parent that stays, or save it to storage.

Typing is still slow after the move

Something above the composer still changes on each keystroke: a context provider whose value includes the draft, or a parent that computes from it. The Profiler’s “Why did this render?” names the cause for each component.

The state resets whenever the page renders

The child is probably declared inside the parent’s function, so each render creates a new component type and React discards the old one’s state.

Pitfall

Declare every component at the top level of its module, never inside another component. React ties state to a component’s type and position, and a component defined inside a render is a new type every time.

Start low, move on evidence

Holding state at the top feels safe, because everything is in sight and nothing will have to move later. It costs more than it looks: a page that renders more than it needs, props that describe a child’s insides, and a component nobody can read without tracing values it never uses. Moving state up is easy to notice, because a second reader asks for it. Moving it down depends on someone noticing, and usually happens only when the typing starts to lag. Start low, and let a second reader be the reason state moves.

Lifting state up in React

When a second component needs the same value and the state has to move up instead.

Deriving state during render

When a value can be computed from what the component already holds.

One status instead of many booleans

When the save’s saving, saved and failed states should be one value, as here.

Adjusting state when a prop changes

When colocated state has to reset because the customer on screen changed.