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.

Adjusting state when a prop changes

Turtlekit Editorial Team

An account manager was halfway through a message to Okafor Logistics when a call came in about Brightside Studio. She clicked Brightside in the customer list, and the panel beside it changed its name, its balance and its invoices. The message box still said “Hi Ade, about the overdue invoice”.

She noticed before sending. The panel kept its own state for the draft and the selected invoice, and React kept that state when the customer changed, because the panel was the same component in the same place with new props. Nothing in the code said the draft belonged to Okafor.

Adjusting state when a prop changes is a question React answers in a fixed order. Compute the value during render if you can. Reset the component with a key if all of its state belonged to the old prop. Adjust during render only the part that must change, and leave the effect for last, which usually means not at all.

The effect that clears too late

The first fix most teams reach for is an effect that clears the state when the customer changes:

// Avoid: resetting state after the render that showed it stale
const [message, setMessage] = useState("");
const [selection, setSelection] = useState<Invoice | null>(null);

useEffect(() => {
  setMessage("");
  setSelection(null);
}, [customer.id]);

An effect runs after React has rendered and updated the DOM. So the switch to Brightside renders the panel, and every child in it, with Okafor’s draft; the effect then clears it, and everything renders again:

One click on Brightside, render by render
StepcustomerThe draft in the DOM
First renderBrightsideHi Ade, about the overdue invoice
The effect runsBrightsideHi Ade, about the overdue invoice
Second renderBrightsideempty

It also has to be kept up to date by hand. Each new piece of state in the panel needs its own line in the effect, and the one somebody forgets is the one that leaks between customers.

Whose state is it?

Before resetting anything, ask what each piece of state belongs to. The draft belongs to one customer, so a different customer should get a different composer. The way the list is filtered belongs to the person reading it, so it should stay. The selected invoice belongs to the list it was chosen from. You Might Not Need an Effect turns those answers into a ladder:

1Compute it during render2Reset everything with a key3Adjust part of it during renderReset in an effect: renders stale first
Try each step in order and stop at the first that fits. Most prop changes stop at the first or the second; the effect at the bottom is the one to avoid.

Each rung is harder to read and easier to get wrong than the one above it, which is why the order matters.

Rebuilding the panel, one piece of state at a time

The panel holds four things. Three of them take a rung: the draft, the selected invoice and the change in the balance. The filter stays as it is.

A different customer, a different composer

Everything in the composer belongs to one customer, so the panel gives it the customer’s id as its key:

// A new customer gets a new composer, and with it an empty draft.
const composer = (
  <MessageComposer
    key={customer.id}
    customer={customer}
    onSend={onSend}
  />
);

React treats two components with different keys as different components, so a new key discards the old one’s state and starts the new one fresh, along with everything inside it. The page that renders the panel passes a customer and never needs to know about the key.

Keeping the filter, resetting the selection

A key on the whole panel would clear the selection, and the filter with it. To reset only part of the state, keep the previous prop in state and compare during render:

// Better: adjust while rendering, before any child sees the stale
// value
const [selection, setSelection] = useState<Invoice | null>(null);
const [prevCustomerId, setPrevCustomerId] = useState(customer.id);
if (customer.id !== prevCustomerId) {
  setPrevCustomerId(customer.id);
  setSelection(null);
}

When the customer changes, React sees the update made during render, discards the JSX this render returned, and renders the component again at once, before any child has seen the stale selection.

Pitfall

Adjust only the rendering component’s own state, and only inside a condition that also updates the previous value; without that update, the component renders in a loop until React stops it. Anything with a side effect, such as a request or a timer, stays in an event handler or an effect.

Better still, nothing to adjust

The selection only needed resetting because it held an invoice. Store the invoice’s id instead and compute the invoice from the current list:

// Best: store the id and compute the invoice, so nothing needs
// resetting
const [selectedId, setSelectedId] = useState<string | null>(null);
const shown =
  filter === "all"
    ? customer.invoices
    : customer.invoices.filter((i) => i.status !== "paid");
const selected =
  shown.find((invoice) => invoice.id === selectedId) ?? null;

Okafor’s invoice id matches nothing in Brightside’s list, so nothing is selected, with no reset at all. If the filter hides the selected invoice, it stops being selected the same way. react.dev calls this the best option of the three, and it is the one the panel keeps.

The one value that needs the render before

The balance updates while the panel is open, and the panel shows how far it moved. That change can’t be computed from this render, because this render only has the new balance. The previous value is the information, so it is kept in state:

const [previous, setPrevious] = useState(balance);
const [change, setChange] = useState(0);
if (balance !== previous) {
  setPrevious(balance);
  setChange(balance - previous);
}

This is the case the useState reference describes as storing information from previous renders, and it is rare. The panel also keys BalanceChange by customer, so opening Brightside shows no change at all rather than the difference between two customers’ balances.

The finished panel

"use client";
// The panel beside the customer list. Each piece of state is answered
// on its own: the draft resets with a key, the selection is computed
// from an id, and the balance keeps its previous value during render.
import { useId, useState } from "react";
import { BalanceChange } from "./balance-change";
import { MessageComposer } from "./message-composer";
import { euros } from "./customer";
import type { Customer, SendMessage } from "./customer";

type Filter = "unpaid" | "all";

export function CustomerPanel({
  customer,
  onSend,
}: {
  customer: Customer;
  onSend: SendMessage;
}) {
  const id = useId();
  // Kept across customers: how this person likes to read the list.
  const [filter, setFilter] = useState<Filter>("unpaid");
  // Best: store the id and compute the invoice, so nothing needs
  // resetting
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const shown =
    filter === "all"
      ? customer.invoices
      : customer.invoices.filter((i) => i.status !== "paid");
  const selected =
    shown.find((invoice) => invoice.id === selectedId) ?? null;

  // A new customer gets a new composer, and with it an empty draft.
  const composer = (
    <MessageComposer
      key={customer.id}
      customer={customer}
      onSend={onSend}
    />
  );

  return (
    <section aria-labelledby={`${id}-name`}>
      <header>
        <h2 id={`${id}-name`}>{customer.name}</h2>
        {/* Each customer starts from their balance, no change. */}
        <BalanceChange key={customer.id} balance={customer.balance} />
      </header>
      <fieldset>
        <legend>Invoices</legend>
        {(["unpaid", "all"] as const).map((value) => (
          <label key={value}>
            <input
              type="radio"
              name={`${id}-filter`}
              checked={filter === value}
              onChange={() => setFilter(value)}
            />
            {value === "unpaid" ? "Unpaid" : "All"}
          </label>
        ))}
      </fieldset>
      <ul aria-label={`${customer.name}’s invoices`}>
        {shown.map((invoice) => (
          <li key={invoice.id}>
            <button
              type="button"
              aria-pressed={invoice.id === selected?.id}
              onClick={() => setSelectedId(invoice.id)}
            >
              {invoice.id}, {euros(invoice.total)}
            </button>
          </li>
        ))}
      </ul>
      {composer}
    </section>
  );
}

Switch from Okafor to Brightside and the message box is empty, labelled for Lena Fischer, while “All” stays chosen and no invoice is selected. When Okafor’s balance rises by €120 while the panel is open, the status line says “Up €120.00 since the last update”.

What each rung costs

The ladder orders the tools by how easy they are to follow. Each also has a cost of its own.

A key resets more than state

A new key recreates the DOM as well as the state. Focus inside the old subtree is lost, scroll positions return to the top, uncontrolled inputs empty, transitions start over, and effects clean up and run again, including any request they make. Key the smallest component that holds the state to reset, as the panel keys its composer and not itself.

What React does with an update during render

React allows an update during render only for the component that is rendering. It finishes running the function, throws the result away and renders again with the new state, before its children render or the screen changes. That makes it cheaper than an effect, and harder to read, which is why react.dev puts it third and asks you to check the first two rungs again before using it.

Why not a usePrevious hook

A common way to keep the last value is a ref written in an effect:

// Avoid: a ref read during render
export function usePrevious<T>(value: T): T {
  const ref = useRef(value);
  useEffect(() => {
    ref.current = value;
  }, [value]);
  return ref.current;
}

It returns ref.current during render, and React asks components not to read refs while rendering, because the result then depends on more than props and state. The hooks lint reports it with its refs rule. The state version above follows the rules and renders the change in the same pass as the balance.

A switch people can follow

The panel’s heading names the open customer, so a screen reader user who switches hears where they are. Focus stays on the list, where the click happened; moving it into the panel without being asked would lose their place. The balance’s change is a status, read politely once.

Testing a prop change

Testing Library’s rerender passes new props to the same component, which is exactly the situation the panel has to handle:

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

const OKAFOR: Customer = {
  id: "cus_2047",
  name: "Okafor Logistics",
  contact: "Ade Okafor",
  balance: 240000,
  invoices: [
    { id: "INV-0047", total: 240000, status: "overdue" },
    { id: "INV-0031", total: 98000, status: "paid" },
  ],
};

const BRIGHTSIDE: Customer = {
  id: "cus_8421",
  name: "Brightside Studio",
  contact: "Lena Fischer",
  balance: 64000,
  invoices: [{ id: "INV-0048", total: 64000, status: "overdue" }],
};

const send = () => Promise.resolve();

test("new customer: no draft or selection, same filter", async () => {
  const user = userEvent.setup();
  const { rerender } = render(
    <CustomerPanel customer={OKAFOR} onSend={send} />,
  );
  await user.type(
    screen.getByRole("textbox", { name: "Message to Ade Okafor" }),
    "Hi Ade,",
  );
  await user.click(screen.getByRole("radio", { name: "All" }));
  await user.click(screen.getByRole("button", { name: /INV-0047/ }));
  expect(
    screen.getByRole("button", { name: /INV-0047/ }),
  ).toHaveAttribute("aria-pressed", "true");

  rerender(<CustomerPanel customer={BRIGHTSIDE} onSend={send} />);
  expect(
    screen.getByRole("textbox", { name: "Message to Lena Fischer" }),
  ).toHaveValue("");
  expect(screen.getByRole("radio", { name: "All" })).toBeChecked();
  expect(
    screen.getByRole("button", { name: /INV-0048/ }),
  ).toHaveAttribute("aria-pressed", "false");
  expect(screen.getByRole("status")).toBeEmptyDOMElement();
});

test("a moved balance says how far, until the next customer", () => {
  const { rerender } = render(
    <CustomerPanel customer={OKAFOR} onSend={send} />,
  );
  expect(screen.getByRole("status")).toBeEmptyDOMElement();
  rerender(
    <CustomerPanel
      customer={{ ...OKAFOR, balance: 252000 }}
      onSend={send}
    />,
  );
  expect(screen.getByText("Balance: €2,520.00")).toBeVisible();
  expect(screen.getByRole("status")).toHaveTextContent(
    "Up €120.00 since the last update",
  );
  rerender(<CustomerPanel customer={BRIGHTSIDE} onSend={send} />);
  expect(screen.getByRole("status")).toBeEmptyDOMElement();
});

Both pass. Remove the composer’s key and the first fails on Okafor’s draft.

Keys from the server

A key is set in JSX, so a Server Component can set it. Some frameworks keep a layout mounted while the address moves from one customer to the next, which raises the same question about the layout’s state. A key on the part that belongs to the customer answers it the same way.

React 19 and the lint

None of this needs a new API, and it all works in React 18. What changed is the tooling. The React hooks lint reports an effect that only sets state, with set-state-in-effect, and an update during render with no condition, with set-state-in-render; the guarded comparison above passes both. The React Compiler leaves the pattern as it is.

When the panel shouldn’t hold the state

Resetting is the right answer when the old state is worthless. An account manager may expect to switch back to Okafor and find her half-written message. Then the drafts belong above the panel, in a map from customer id to draft held by the page, or in storage, as Lifting state up in React describes.

Data that belongs to a customer, such as their invoices, shouldn’t be copied into the panel’s state and adjusted at all. Read it from props or from the data layer, keyed by the customer’s id.

How Turtlekit marks the open customer and its change

In a list beside its detail, Item’s current marks the open row with a bar at its start edge and sets aria-current on its link. Metric draws a change you give it and works out whether it is good from better, so the previous-value bookkeeping stays in one small component:

// The customers page with Turtlekit: the list beside the detail marks
// the open customer with Item's current, and Metric draws the
// balance's change.
import { useState } from "react";
import { Item, ItemList, Metric } from "turtlekit";
import type { Customer } from "./customer";

export function CustomerList({
  customers,
  openId,
}: {
  customers: readonly Customer[];
  openId: string;
}) {
  return (
    <ItemList label="Customers" variant="outline">
      {customers.map((customer) => (
        <Item
          key={customer.id}
          href={`/customers/${customer.id}`}
          title={customer.name}
          description={customer.contact}
          current={customer.id === openId}
        />
      ))}
    </ItemList>
  );
}

/**
 * Render with key={customer.id}, so each customer starts with no
 * change.
 */
export function BalanceMetric({ balance }: { balance: number }) {
  const [previous, setPrevious] = useState(balance);
  const [change, setChange] = useState(0);
  if (balance !== previous) {
    setPrevious(balance);
    setChange(balance - previous);
  }
  return (
    <Metric
      label="Balance"
      value={balance / 100}
      format="currency"
      change={change / 100}
      changeFormat="currency"
      period="since the last update"
      better="down"
    />
  );
}

DataTable takes a currentRowId for the same layout. Pagination needs no reset either: when a filter leaves fewer pages than the current one, it shows the last page, with nothing for the page around it to correct.

When a reset goes wrong

Three errors account for most of the trouble with state that follows a prop.

“Too many re-renders”

State is set during render without a condition, or the condition never becomes false. Update the previous value inside the same condition as the adjustment.

“Cannot update a component while rendering a different component”

A child sets its parent’s state during render. Adjust the state in the component that owns it, or move the update into the event that caused the change.

A field loses focus after every keystroke

Its key changes on every render, from a random value or from something derived from what is being typed. A key should change only when the thing it names changes, such as the customer’s id.

Belonging, not syncing

The effect that resets state on a prop change reads as diligence: something changed, so tidy up. It treats the symptom, two customers sharing one draft, and leaves the cause, which is that nothing said the draft belonged to a customer. A key says it in one attribute. An id instead of a copy makes the question disappear. What is left for the render-time update is small: a value that only the previous render knew, and naming that case precisely is most of the skill.

State colocation in React

When the question is which component should hold the draft in the first place.

Lifting state up in React

When each customer’s draft should survive switching away and back.

Deriving state during render

When the value can be computed during render, the first rung of the ladder.

One status instead of many booleans

When the state that must reset is an async check’s progress.