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.

Deriving state during render

Turtlekit Editorial Team

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.

InputsComputedinvoicesquerystatusselectedIdsvisibleowedremindable
The left column is the input: the invoices arrive as a prop, and the three choices below them are state. The right column is worked out from it on every render, so it is never older than the invoices it came from.

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:

The same two ticks, after the change
MomentselectedIdsThe button
Two overdue invoices ticked0047, 0048Send 2 reminders
A colleague marks 0047 paid0047, 0048Send 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.

State colocation in React

When the question is where the stored values should live.

Lifting state up in React

When two components read the same choice, and the computed values follow it.

One status instead of many booleans

When stored flags contradict each other instead of a derived value.

Adjusting state when a prop changes

When a value depends on the last render, and can’t be computed from this one.