On a Monday morning a customer forwarded a payment reminder for an invoice they had paid on Friday. The finance lead had ticked two overdue invoices, a colleague had marked one of them paid in the meantime, and the list still offered to send two reminders. One of them went out for money already received.
The list stored everything it showed: the filtered rows, the outstanding total, and the invoices people had ticked, copied whole at the moment of the tick. Effects kept the first two in step with the invoices. Nothing kept the ticked copies in step, so they stayed exactly as they were when ticked.
Deriving state means computing whatever follows from props and state during render, instead of storing it. Store what people chose, the search, the filter and the ids they ticked, and work everything else out from the latest invoices each time the list renders.
Copies that fall behind
Here is the top of the first list. Count the values that could be worked out from the others:
// Avoid: values that follow from the invoices, stored and synced
// later
const [query, setQuery] = useState("");
const [visible, setVisible] = useState(invoices);
const [total, setTotal] = useState(0);
const [selected, setSelected] = useState<Invoice[]>([]);
useEffect(() => {
setVisible(filterInvoices(invoices, query, "all"));
}, [invoices, query]);
useEffect(() => {
setTotal(outstanding(visible));
}, [visible]);Two of the four. visible and total follow from the invoices and the query, and each effect updates them after the render that needed them. The effects are chained, so a change to the invoices renders the list three times: with the old rows and total, then with the new rows and the old total, then with both up to date, as You Might Not Need an Effect describes. The React hooks lint flags both effects with its set-state-in-effect rule.
The third, selected, is a choice kept in the wrong shape, and nothing syncs it. It holds copies of whole invoices, so when a colleague marks one paid, the copy still says overdue, and the reminder goes out for money already paid.
Cells you type into, and formulas
A spreadsheet is the model to keep. Some cells are typed into; the rest are formulas over them, and nobody types a total into a cell. In a component, state is the cells people type into: the search, the filter, the ticks. Everything else is a formula, evaluated on every render.
Store what people chose; compute what follows from it. React’s guide to choosing the state structure calls the alternative redundant state: if a value can be calculated from props or state during render, it should not be in state.
Rebuilding the list from what people chose
The new list keeps three values and computes three more. The pure functions it calls, such as filterInvoices and outstanding, live in a plain module beside it.
Keep only the choices
Each stored value is something people did, and none of them can be computed:
const [query, setQuery] = useState("");
const [status, setStatus] = useState<StatusFilter>("all");
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(
() => new Set(),
);The selection is a set of ids rather than a list of invoices. An id says which invoice was ticked without freezing what that invoice looked like at the time.
Compute the rest on every render
The rows, the amount owed and the invoices that can be reminded are three plain constants:
const visible = filterInvoices(invoices, query, status);
const owed = outstanding(visible);
// From the latest invoices, so a change made elsewhere shows here
// at once.
const remindable = invoices.filter(
(invoice) =>
selectedIds.has(invoice.id) && invoice.status === "overdue",
);There are no effects and no second render, and no frame shows an old total. Here is the Monday morning again, with the ids stored and the reminders computed:
| Moment | selectedIds | The button |
|---|---|---|
| Two overdue invoices ticked | 0047, 0048 | Send 2 reminders |
| A colleague marks 0047 paid | 0047, 0048 | Send 1 reminder |
The stored ids never changed. The fresh invoices did, and the formula read them. The tick on 0047 stays, because the finance lead did tick it, and the reminder for it is no longer offered.
The finished list
"use client";
// The invoice list: it stores what people chose (the search, the
// filter and the ids they ticked) and computes everything else during
// render.
import { useId, useState } from "react";
import {
FILTERS,
euros,
filterInvoices,
outstanding,
reminderLabel,
} from "./invoices";
import type { Invoice, StatusFilter } from "./invoices";
const STATUS = {
draft: "Draft",
open: "Open",
overdue: "Overdue",
paid: "Paid",
};
interface InvoiceListProps {
invoices: readonly Invoice[];
onRemind: (ids: string[]) => void;
}
export function InvoiceList({
invoices,
onRemind,
}: InvoiceListProps) {
const id = useId();
const [query, setQuery] = useState("");
const [status, setStatus] = useState<StatusFilter>("all");
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(
() => new Set(),
);
const visible = filterInvoices(invoices, query, status);
const owed = outstanding(visible);
// From the latest invoices, so a change made elsewhere shows here
// at once.
const remindable = invoices.filter(
(invoice) =>
selectedIds.has(invoice.id) && invoice.status === "overdue",
);
function toggle(invoiceId: string) {
setSelectedIds((current) => {
const next = new Set(current);
if (!next.delete(invoiceId)) next.add(invoiceId);
return next;
});
}
const count =
visible.length === 1 ? "1 invoice" : `${visible.length} invoices`;
return (
<section aria-labelledby={`${id}-title`}>
<h2 id={`${id}-title`}>Invoices</h2>
<label htmlFor={`${id}-search`}>Search invoices</label>
<input
id={`${id}-search`}
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<label htmlFor={`${id}-status`}>Status</label>
<select
id={`${id}-status`}
value={status}
onChange={(event) =>
setStatus(
FILTERS.find((f) => f === event.target.value) ?? "all",
)
}
>
{FILTERS.map((filter) => (
<option key={filter} value={filter}>
{filter === "all" ? "All" : STATUS[filter]}
</option>
))}
</select>
<p role="status">
{count}, {euros(owed)} outstanding
</p>
<ul>
{visible.map((invoice) => (
<li key={invoice.id}>
<label>
<input
type="checkbox"
checked={selectedIds.has(invoice.id)}
onChange={() => toggle(invoice.id)}
/>
{invoice.id}, {invoice.customer}: {euros(invoice.total)}
, {STATUS[invoice.status]}
</label>
</li>
))}
</ul>
<button
type="button"
disabled={remindable.length === 0}
onClick={() =>
onRemind(remindable.map((invoice) => invoice.id))
}
>
{reminderLabel(remindable.length)}
</button>
</section>
);
}Type “brightside” and the status line changes from “5 invoices, €3,135.00 outstanding” to “1 invoice, €640.00 outstanding” in the same render as the rows. Tick two overdue invoices and the button offers two reminders; when one is paid elsewhere, it offers one.
What computing on every render costs
A formula runs every time, and that is the one real objection to the pattern. Most of the answers to it are cheaper than they look.
Measure before memoising
Filtering a few hundred invoices rarely shows up in a profile. React’s guidance is that unless you are creating or looping over thousands of objects, a calculation is probably not expensive. Measure it with console.time while typing, in a production build, with the CPU throttled to something like your customers’ laptops.
When it does show, cache the same formula with useMemo:
// For a list long enough that filtering it shows in a measurement:
// the same derivation, cached until one of its inputs changes.
import { useMemo } from "react";
import { filterInvoices } from "./invoices";
import type { Invoice, StatusFilter } from "./invoices";
export function useVisibleInvoices(
invoices: readonly Invoice[],
query: string,
status: StatusFilter,
) {
// Runs again only when invoices, query or status is a different
// value.
return useMemo(
() => filterInvoices(invoices, query, status),
[invoices, query, status],
);
}useMemo doesn’t speed up the first render; it skips the work on later ones. It also compares its inputs by identity, so a parent that builds a new invoices array on every render turns it into overhead.
One formula, many readers
Two components may show the same conclusion, such as the amount owed in the page heading and in the list. Compute it once in their common owner and pass it down, or let both call the same pure function. Either way there is one formula. Storing the result where both can reach it brings back the copy this pattern removed.
A prop copied into state stops listening
useState(invoices) reads the prop once, on the first render, and ignores it after. Copy a prop into state only when ignoring later values is the point, as with a form’s starting values, and name it initialInvoices or defaultValue so the next reader knows.
A count people can hear
The status line is computed from the same visible rows the list renders, so what a screen reader announces after each keystroke always matches the screen. Each checkbox is named by the invoice’s number and customer, so ticking one is never an unnamed “checkbox, checked”.
Testing a formula
Derived values are pure functions of their inputs, which makes them the easiest code in the component to test. The formulas get plain-value tests; the component gets one test per promise, including a re-render with fresh invoices:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { InvoiceList } from "./invoice-list";
import { filterInvoices, outstanding } from "./invoices";
import type { Invoice } from "./invoices";
const INVOICES: Invoice[] = [
{
id: "INV-0046",
customer: "Fernhill Dental",
status: "paid",
total: 120000,
},
{
id: "INV-0047",
customer: "Okafor Logistics",
status: "overdue",
total: 240000,
},
{
id: "INV-0048",
customer: "Brightside Studio",
status: "overdue",
total: 64000,
},
{
id: "INV-0049",
customer: "Halden & Co",
status: "open",
total: 9500,
},
{
id: "INV-0050",
customer: "Fernhill Dental",
status: "draft",
total: 12000,
},
];
test("the formulas are plain functions", () => {
expect(outstanding(INVOICES)).toBe(313500);
expect(
filterInvoices(INVOICES, "fernhill", "draft").map((i) => i.id),
).toEqual(["INV-0050"]);
});
test("the count and total follow the search as typed", async () => {
const user = userEvent.setup();
render(<InvoiceList invoices={INVOICES} onRemind={() => {}} />);
expect(screen.getByRole("status")).toHaveTextContent(
"5 invoices, €3,135.00 outstanding",
);
await user.type(
screen.getByRole("searchbox", { name: "Search invoices" }),
"brightside",
);
expect(screen.getByRole("status")).toHaveTextContent(
"1 invoice, €640.00 outstanding",
);
});
test("an invoice paid elsewhere leaves the reminders", async () => {
const user = userEvent.setup();
const onRemind = vi.fn();
const { rerender } = render(
<InvoiceList invoices={INVOICES} onRemind={onRemind} />,
);
await user.click(
screen.getByRole("checkbox", { name: /INV-0047/ }),
);
await user.click(
screen.getByRole("checkbox", { name: /INV-0048/ }),
);
expect(
screen.getByRole("button", { name: "Send 2 reminders" }),
).toBeEnabled();
// A colleague marks INV-0047 paid; the page passes the fresh
// invoices.
const fresh = INVOICES.map((i) =>
i.id === "INV-0047" ? { ...i, status: "paid" as const } : i,
);
rerender(<InvoiceList invoices={fresh} onRemind={onRemind} />);
await user.click(
screen.getByRole("button", { name: "Send 1 reminder" }),
);
expect(onRemind).toHaveBeenCalledWith(["INV-0048"]);
});All three pass. Store the selected invoices instead of their ids and the third fails, still offering two reminders.
Deriving on the server
A value computed from data needs no state at all, so a Server Component can compute it before the HTML is sent: the amount owed in a page heading costs the browser nothing. The list stays a Client Component, because the search and the ticks are state, and it derives the rest where it renders.
React 19 and the Compiler
The React Compiler memoises values computed during render, which leaves very few reasons to write useMemo by hand in new code. Existing memos can stay until you have tested without them, as the Compiler’s own guidance advises. The React hooks lint, which now carries the Compiler’s checks, reports an effect that only sets state before it ships.
When a value can’t be worked out from now
Some values depend on history rather than on the current inputs. Whether an amount went up since the last render needs the previous amount, which this render doesn’t have; Adjusting state when a prop changes covers that case. A value that needs a request belongs in the data layer, not in a formula.
And a value people can edit is a choice, not a conclusion. A reminder’s message prefilled from the invoice is state, started from the invoice once and then owned by the person typing.
How Turtlekit’s DataTable and Metric compute what they show
DataTable stores the selection as ids, through selected and onSelectedChange, and computes its sorted rows and the select-all box’s checked or mixed state on each render. Metric takes the value and its change, and works out the trend and whether it is good from better; it has no prop for either, so they can’t disagree with the figures.
// The invoice list with Turtlekit: DataTable keeps the selection as
// ids and computes its sorted rows and select-all state; Metric
// computes its trend.
import { useState } from "react";
import { Button, DataTable, Metric, Stack } from "turtlekit";
import type { DataTableColumn } from "turtlekit";
import { outstanding, reminderLabel } from "./invoices";
import type { Invoice } from "./invoices";
const COLUMNS: DataTableColumn<Invoice>[] = [
{ key: "id", header: "Invoice", sortable: true },
{ key: "customer", header: "Customer", sortable: true },
{ key: "status", header: "Status" },
{
key: "total",
header: "Total",
value: (i) => i.total / 100,
format: "currency",
sortable: true,
},
];
interface InvoicesProps {
invoices: readonly Invoice[];
lastMonth: number;
onRemind: (ids: string[]) => void;
}
export function Invoices({
invoices,
lastMonth,
onRemind,
}: InvoicesProps) {
const [selected, setSelected] = useState<string[]>([]);
const owed = outstanding(invoices);
const remindable = invoices.filter(
(i) => selected.includes(i.id) && i.status === "overdue",
);
return (
<Stack gap="group">
<Metric
label="Outstanding"
value={owed / 100}
format="currency"
change={(owed - lastMonth) / lastMonth}
period="on last month"
better="down"
/>
<DataTable
caption="Invoices"
columns={COLUMNS}
rows={invoices}
getRowId={(i) => i.id}
getRowLabel={(i) => `${i.id}, ${i.customer}`}
rowHeader="id"
selectable
selected={selected}
onSelectedChange={setSelected}
bulkActions={() => (
<Button
size="1"
unavailable={
remindable.length
? undefined
: "Select an overdue invoice"
}
onClick={() => onRemind(remindable.map((i) => i.id))}
>
{reminderLabel(remindable.length)}
</Button>
)}
/>
</Stack>
);
}The reminder button reads the fresh invoices, as the hand-built list does, and Button’s unavailable says why it can’t be used yet.
Built on this pattern in Turtlekit: Calendar, Call to action, Carousel, Chart, Code block, Combobox, Command, Data table, Date input, Image slider, Metric, Pagination, Progress, Root and Site footer.
When a computed value looks wrong
Three symptoms point back to a stored value that should have been a formula.
The total shows the old figure for a moment
It is stored and synced in an effect, so the renders before the effect runs show it stale. Compute it during render and delete the effect.
useMemo made no difference
Either the work was cheap to begin with, or an input is a new object or array on every render, so the cache never hits. Check where each dependency is created.
A changed invoice still shows its old values when selected
The selection holds copies. Store the ids and look the invoices up in the current list during render.
Fewer facts to keep true
Every value a component stores is a fact it promises to keep true. A list that stores its totals has signed up to update them on every path that changes an invoice, including the paths someone writes next year. A list that computes them has nothing to keep true but the choices people made. It is less code, it renders once instead of three times, and on a Monday morning it sends the reminders people meant to send.