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.
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.
| Value | Read by | Lives in |
|---|---|---|
| The draft | The note box | The note box |
| Older invoices shown | The history | The history |
| The customer | Every part | The 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.