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.

Single responsibility in React components

Turtlekit Editorial Team

Finance asked for a small thing: overdue invoices should say how late they are, as “3 days overdue”. The change to the invoice panel was two lines. The test for it began by faking a server, because the sentence was worked out inside a component that fetched the invoice when it mounted.

Nobody had planned it that way. The panel had loaded the invoice from the start, then learned to word the due date, then to send a reminder, then to announce whether the reminder went. Each addition was small and sensible where it landed.

The rule that explains the fake server is an old one, single responsibility: a component should have one reason to change. In a React codebase the reasons usually have names, because they arrive as requests from the people who own them.

Four requests, one file

Here is the panel’s logic, as it stood when finance’s request arrived:

const [invoice, setInvoice] = useState<Invoice | null>(null);
const [failed, setFailed] = useState(false);
const [sending, setSending] = useState(false);
const [notice, setNotice] = useState("");
const [now] = useState(() => Date.now());

useEffect(() => {
  let ignore = false;
  fetch(`/api/invoices/${invoiceId}`)
    .then((response) => response.json() as Promise<Invoice>)
    .then((data) => !ignore && setInvoice(data))
    .catch(() => !ignore && setFailed(true));
  return () => {
    ignore = true;
  };
}, [invoiceId]);

async function remind() {
  setSending(true);
  const url = `/api/invoices/${invoiceId}/reminders`;
  const response = await fetch(url, { method: "POST" });
  setSending(false);
  setNotice(
    response.ok
      ? "Reminder sent."
      : "The reminder didn’t send. Try again.",
  );
}

And its rendering, where the due date is worked out between two guards and the markup:

if (failed) return <p role="alert">This invoice couldn’t load.</p>;
if (!invoice) return <p>Loading invoice…</p>;

const days = Math.round((Date.parse(invoice.dueDate) - now) / DAY);
const due =
  days < 0 ? `${-days} days overdue` : `Due in ${days} days`;

return (
  <section aria-labelledby="invoice-title">
    <h2 id="invoice-title">{invoice.id}</h2>
    <p>
      {invoice.customer} · {invoice.total}
    </p>
    <p>{due}</p>
    {invoice.status === "overdue" && (
      <button type="button" onClick={remind} disabled={sending}>
        {sending ? "Sending…" : "Send reminder"}
      </button>
    )}
    <p role="status">{notice}</p>
  </section>
);

Read it for the requests it can receive. The backend can rename a field or move an endpoint. Finance can change the wording. Support can ask what happens when a reminder fails. Design can rearrange the panel. All four land in this one function, so each change is reviewed beside the other three, and each test has to set up all four.

The wording also hides a bug. The due date counts milliseconds from now and rounds, so after midday UTC an invoice due in three days reads “Due in 2 days”. Nobody had noticed, because nobody could test the sentence without a server and a frozen clock.

Count the people who could ask for a change

Single responsibility is often read as “keep components small”. The more useful reading is that every component, hook or function answers to one owner. To find the seams in a component, list who could ask for it to change, and give each of them a place of their own.

A change tolands inthe APIasked by backendapi.tsthe wordingasked by financedueLabelthe reminderasked by supportSendReminderthe layoutasked by designInvoiceSummary
Each request that can reach the invoice panel now has one place to land. The panel itself changes only when what goes where changes.

react.dev puts the same advice in Thinking in React: a component should ideally be concerned with one thing, and decomposed when it grows. The question is what counts as one thing, and the requests are the most reliable answer.

Giving each request a place to land

The two fetch calls move first, into the one file that knows the invoice API’s addresses and what its errors look like. Everything after this imports from it:

// The one file that knows the invoice API's addresses and answers.
import type { Invoice } from "./invoice";

export async function fetchInvoice(id: string): Promise<Invoice> {
  const response = await fetch(`/api/invoices/${id}`);
  if (!response.ok)
    throw new Error(`Invoice ${id}: ${response.status}`);
  return (await response.json()) as Invoice;
}

export async function sendReminder(id: string): Promise<void> {
  const response = await fetch(`/api/invoices/${id}/reminders`, {
    method: "POST",
  });
  if (!response.ok)
    throw new Error(`Reminder for ${id}: ${response.status}`);
}

The wording becomes a function

The due date never needed to be in a component. It is a function of two dates, and the important change is that the clock becomes one of its arguments:

// The due date in the product's words. The clock is an argument, so
// the wording can be tested with any date and changed without
// touching a screen.
const DAY = 86_400_000;

/** Whole calendar days from today to the due date, counted in UTC. */
export function daysUntil(dueDate: string, today: Date): number {
  const due = Date.parse(`${dueDate}T00:00:00Z`);
  const start = Date.UTC(
    today.getUTCFullYear(),
    today.getUTCMonth(),
    today.getUTCDate(),
  );
  return Math.round((due - start) / DAY);
}

export function dueLabel(dueDate: string, today: Date): string {
  const days = daysUntil(dueDate, today);
  if (days === 0) return "Due today";
  if (days === 1) return "Due tomorrow";
  if (days > 1) return `Due in ${days} days`;
  return days === -1 ? "1 day overdue" : `${-days} days overdue`;
}

Counting calendar days instead of rounding milliseconds fixes the afternoon bug, and the fix can be proven with two dates and no render. The invoices are dated in UTC, so the count is too; a product that dates them in each customer’s time zone would pass that zone in as well. Finance’s next request is now a change to one file whose tests run in milliseconds.

Loading moves into a hook

The fetch and its two outcomes become one status, so the panel can’t hold a loaded invoice and an error at the same time:

"use client";
// Loading the invoice, as one status. The result remembers which
// invoice it belongs to, so switching invoices shows loading, never
// the previous one.
import { useEffect, useState } from "react";
import { fetchInvoice } from "./api";
import type { Invoice } from "./invoice";

export type InvoiceState =
  | { status: "loading" }
  | { status: "failed" }
  | { status: "ready"; invoice: Invoice };

export function useInvoice(invoiceId: string): InvoiceState {
  const [result, setResult] = useState<{
    id: string;
    state: InvoiceState;
  }>();

  useEffect(() => {
    let ignore = false;
    fetchInvoice(invoiceId).then(
      (invoice) =>
        !ignore &&
        setResult({
          id: invoiceId,
          state: { status: "ready", invoice },
        }),
      () =>
        !ignore &&
        setResult({ id: invoiceId, state: { status: "failed" } }),
    );
    return () => {
      ignore = true;
    };
  }, [invoiceId]);

  return result?.id === invoiceId
    ? result.state
    : { status: "loading" };
}

The result records which invoice it belongs to. When the page switches from INV-0048 to INV-0049, the hook reports loading on that very render, instead of showing the old invoice until the new one arrives. The ignore flag drops an answer that comes back for an invoice nobody is looking at any more.

The reminder owns its pending state and its message

Sending a reminder is an action with a beginning, a wait and an outcome. In React 19 that is what useActionState is for:

"use client";
// The reminder: what sending means, what pending looks like, and what
// people hear when it succeeds or fails.
import { useActionState } from "react";
import { sendReminder } from "./api";

export function SendReminder({ invoiceId }: { invoiceId: string }) {
  const [message, send, isPending] = useActionState(async () => {
    try {
      await sendReminder(invoiceId);
      return "Reminder sent.";
    } catch {
      return "The reminder didn’t send. Try again.";
    }
  }, "");

  return (
    <form action={send}>
      {/* aria-disabled, not disabled: the button keeps focus. */}
      <button
        type="submit"
        aria-disabled={isPending}
        onClick={(event) => isPending && event.preventDefault()}
      >
        {isPending ? "Sending…" : "Send reminder"}
      </button>
      <p role="status">{message}</p>
    </form>
  );
}

The hook tracks the pending flag, so the hand-written sending state is gone. While the request runs, the button is marked aria-disabled rather than disabled, because disabling a focused button drops keyboard focus to the page. The status message lives beside the action it reports, which matters for screen readers, as the next chapter explains.

The summary renders what it is given

What remains of the markup takes an invoice, a date and an optional slot for actions. It fetches nothing and knows nothing about reminders:

// The invoice as people read it. It renders what it is given and
// leaves a slot for whatever actions the page offers.
import { useId } from "react";
import type { ReactNode } from "react";
import { dueLabel } from "./due-label";
import type { Invoice } from "./invoice";

interface InvoiceSummaryProps {
  invoice: Invoice;
  today: Date;
  actions?: ReactNode;
}

export function InvoiceSummary({
  invoice,
  today,
  actions,
}: InvoiceSummaryProps) {
  const titleId = useId();
  return (
    <section aria-labelledby={titleId}>
      <h2 id={titleId}>{invoice.id}</h2>
      <p>
        {invoice.customer} · {invoice.total}
      </p>
      <p>{dueLabel(invoice.dueDate, today)}</p>
      {actions}
    </section>
  );
}

The panel decides what goes where

The panel that used to do everything now composes the pieces:

"use client";
// The invoice panel after the split: it decides what goes where, and
// nothing else. `today` comes from the page, so every render agrees
// on it.
import { InvoiceSummary } from "./invoice-summary";
import { SendReminder } from "./send-reminder";
import { useInvoice } from "./use-invoice";

interface InvoicePanelProps {
  invoiceId: string;
  today: Date;
}

export function InvoicePanel({
  invoiceId,
  today,
}: InvoicePanelProps) {
  const state = useInvoice(invoiceId);
  if (state.status === "failed")
    return <p role="alert">This invoice couldn’t load.</p>;
  if (state.status === "loading") return <p>Loading invoice…</p>;

  const { invoice } = state;
  const overdue = invoice.status === "overdue";
  return (
    <InvoiceSummary
      invoice={invoice}
      today={today}
      actions={
        overdue ? <SendReminder invoiceId={invoice.id} /> : null
      }
    />
  );
}

Open INV-0048 on 15 November and the panel shows “Loading invoice…”, then the summary with “3 days overdue” and a Send reminder button. Press it and the button reads “Sending…” until the server answers, then the status line says “Reminder sent.” Finance’s request, had it arrived today, would have touched due-label.ts and nothing else.

What the split costs, and what it pays back

Six files where there was one is a real cost, and it is worth knowing what it buys before paying it.

Split along reasons, not along length

A long component with one reason to change is fine; a short one with three is not. Every split adds a file to open and a prop to follow, so make one when two reasons have actually collided, in a review or a test, rather than in anticipation. The invoice panel earned its split the day a wording change needed a network mock.

State that lives where it changes

Before the split, the sending flag lived in the panel, so pressing the button re-rendered the whole panel twice. Now the pending state is inside SendReminder, and only the button and its message re-render. On one invoice that is invisible. In a list of fifty overdue invoices, each with its own reminder, it is the difference between one row updating and the list updating.

One owner for each announcement

A status message is only heard if its live region exists before the text changes. The status paragraph renders, empty, as soon as the form does, so “Reminder sent.” is announced when it appears. Keeping it in the component that owns the action also keeps announcements from doubling up: the panel announces that loading failed, the reminder announces the reminder, and neither speaks for the other.

Tests that follow the reasons

Each test now sets up only what its reason needs. The wording is checked with dates, the summary with a plain object, and the panel’s one integration test mocks the module that owns the API rather than fetch itself:

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { fetchInvoice, sendReminder } from "./api";
import { dueLabel } from "./due-label";
import type { Invoice } from "./invoice";
import { InvoicePanel } from "./invoice-panel";
import { InvoiceSummary } from "./invoice-summary";

vi.mock("./api", () => ({
  fetchInvoice: vi.fn(),
  sendReminder: vi.fn(),
}));

const TODAY = new Date("2026-11-15T09:00:00Z");
const OVERDUE: Invoice = {
  id: "INV-0048",
  customer: "Tomás Ortega",
  total: "€640.00",
  dueDate: "2026-11-12",
  status: "overdue",
};

test("the wording is a function of two dates", () => {
  expect(dueLabel("2026-11-12", TODAY)).toBe("3 days overdue");
  expect(dueLabel("2026-11-16", TODAY)).toBe("Due tomorrow");
  // Late in the day it still counts calendar days.
  expect(
    dueLabel("2026-11-18", new Date("2026-11-15T23:30:00Z")),
  ).toBe("Due in 3 days");
});

test("the summary renders from plain data, with no server", () => {
  render(<InvoiceSummary invoice={OVERDUE} today={TODAY} />);
  expect(
    screen.getByRole("region", { name: "INV-0048" }),
  ).toHaveTextContent("3 days overdue");
});

test("overdue: offers a reminder, then confirms it", async () => {
  vi.mocked(fetchInvoice).mockResolvedValue(OVERDUE);
  vi.mocked(sendReminder).mockResolvedValue();
  render(<InvoicePanel invoiceId="INV-0048" today={TODAY} />);
  await userEvent.click(
    await screen.findByRole("button", { name: "Send reminder" }),
  );
  expect(await screen.findByRole("status")).toHaveTextContent(
    "Reminder sent.",
  );
  expect(sendReminder).toHaveBeenCalledWith("INV-0048");
});

All three pass, and the late-evening date in the first test is the one the old panel got wrong.

A seam the server can use

The split lines up with the line Server Components draw. dueLabel and InvoiceSummary hold no state and carry no directive, so they render on the server when server code imports them. In a framework that loads data in Server Components, the page reads the invoice on the server and useInvoice disappears, along with the client panel that called it. Only SendReminder still needs "use client", a small interactive island inside server-rendered HTML.

Passing today from the page matters here too. The server chooses the date once, and a Date is a prop a Client Component may receive. The HTML and the hydrated page then agree on “3 days overdue”, even when the page is rendered a second before midnight UTC.

React 19 writes the pending state for you

useActionState and form actions replace a flag set before an await and cleared after it. React 18 has neither, so there the component keeps its own useState for pending and its own try and finally. The React Compiler takes away another old reason to split, which was to create a boundary for memo. What is left is the reason that was always the better one: who asks for the change.

Deep dive

Why is dueLabel a function and not a component? A component is worth it when a piece needs state, effects or a place in the tree. A sentence computed from two dates needs none of those, and as a function it can be called from the invoice list, an email template or a CSV export, none of which render React.

When one component is the right size

Some pieces always change together. A table row and its cells, or a form section that is designed, reviewed and shipped as one, have a single owner however many elements they render. Splitting them produces props that do nothing but pass through.

When a component has started to blur but no request has collided yet, extract functions inside the same file first. A pure function next to the component is a split with no new file, and it is usually the first piece that wants to leave.

How Turtlekit’s PageHeader, DataList and Badge share an invoice

Turtlekit’s components are divided the same way, so an invoice summary is assembled rather than written. PageHeader owns the title row and where the actions sit, DataList the label and value pairs, and Badge the status in words and a tone.

// The invoice summary with Turtlekit: the title row, the facts and
// the status are each one component's job, and the actions are still
// a slot.
import type { ReactNode } from "react";
import { Badge, DataList, PageHeader, Stack } from "turtlekit";
import { dueLabel } from "./due-label";
import type { Invoice } from "./invoice";

const STATUS = {
  draft: { tone: "neutral", word: "Draft" },
  sent: { tone: "info", word: "Sent" },
  paid: { tone: "success", word: "Paid" },
  overdue: { tone: "danger", word: "Overdue" },
} as const;

interface InvoiceSummaryProps {
  invoice: Invoice;
  today: Date;
  actions?: ReactNode;
}

export function InvoiceSummary({
  invoice,
  today,
  actions,
}: InvoiceSummaryProps) {
  const status = STATUS[invoice.status];
  return (
    <Stack gap="group">
      <PageHeader
        headingLevel={2}
        title={invoice.id}
        description={`Billed to ${invoice.customer}.`}
        actions={actions}
      />
      <DataList
        items={[
          {
            label: "Status",
            value: <Badge tone={status.tone}>{status.word}</Badge>,
          },
          { label: "Due", value: dueLabel(invoice.dueDate, today) },
          { label: "Total", value: invoice.total, numeric: true },
        ]}
      />
    </Stack>
  );
}

The split from the build carries over unchanged: the actions still arrive through a slot, and the due row is still dueLabel, so finance’s wording has one home. The summary’s own code is left with one job, deciding which facts an invoice shows.

Signs a component has taken on another job

Each of these is a second reason to change, showing up somewhere it can be seen.

A test needs a fake server to check a sentence

The sentence is computed inside a component that loads data. Move the computation to a function that takes everything it reads, the clock included, as arguments.

A boolean prop switches half the component off

Props such as readOnly or hideActions usually mean two components share one body. Give the shared part a slot, as the summary’s actions does, and let each caller fill it.

A prop now travels through three components to reach one

The split followed the markup instead of the reasons. Pass the finished element down as a child or a slot, so the components in between never see the prop at all.

Name the reason before you split

The best time to split a component is the day two requests collide in it, and not long before. Split earlier and you guess at owners who never show up, leaving props that only pass things through. Split much later and every change carries a negotiation between people who never meant to share a file. The invoice panel waited for a wording change to need a fake server, which is late enough to be sure and early enough to be cheap. Listen for that moment in review, when someone asks why a small change touched so much.

Presentational and container components in React

When the split you need is between what a component shows and where its data comes from.

Children, function children and render props in React

When a component has to leave room for content another component owns.

Compound components in React

When one job is spread across parts that must stay wired together.

The variant pattern in React

When the reason to change is how something looks, decided once for every screen.