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:
| Step | customer | The draft in the DOM |
|---|---|---|
| First render | Brightside | Hi Ade, about the overdue invoice |
| The effect runs | Brightside | Hi Ade, about the overdue invoice |
| Second render | Brightside | empty |
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:
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.