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.
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.