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.

One status instead of many booleans

Turtlekit Editorial Team

Someone in finance changed the IBAN that payouts go to, pressed “Verify account” and watched the button spin under a red message saying the account couldn’t be verified. Both were on screen at once. They clicked again, to be sure, and the bank received two checks for the same account.

The component tracked the check with three flags: checking, an error and verified. Retrying set checking to true and left the last error where it was, and nothing stopped a second click while the first request was out. Three booleans can describe eight screens. The process has four.

One status replaces the flags: a single value that names the state the process is in, idle, checking, verified or failed, carrying the data that state needs. A spinner under an error can’t render, because no value of the status means both.

Three flags, eight screens

Here is the check as it shipped. Each line is reasonable on its own:

// Avoid: three flags for one process
const [isChecking, setIsChecking] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isVerified, setIsVerified] = useState(false);

async function handleCheck() {
  setIsChecking(true); // the last error stays on screen
  try {
    await checkAccount(iban);
    setIsVerified(true);
  } catch {
    setError("We couldn’t verify this account.");
  } finally {
    setIsChecking(false);
  }
}

Nothing clears error when a new check starts, so a retry shows the spinner and the old failure together. Nothing ignores a click while checking, so a double click sends two requests. Each fix is one more line in the handler, and each new state, such as a pending manual review, doubles the combinations again.

The combinations the three flags allow and the process never has
Flags setOn screen
checking, errorA spinner under a failure
checking, verifiedA spinner beside a confirmation
error, verifiedA failure beside a confirmation
all threeAll of the above

A dial, not a row of switches

Switches can be set in any combination. A dial points at one position at a time. When two flags should never be true together, they were one value all along, and the dial is the right shape for it. React’s guide to choosing the state structure makes the same call for a form’s sending and sent flags.

checkcheck againIBAN editedidlecheckingverifiedfailed
One value, four states, and a short list of moves between them. Editing the IBAN returns to idle from any state, and a verified account can be checked again. A screen with a spinner and an error has no state to be drawn from.

The positions are the states the process can be in. The moves between them are a short, written list, and anything not on the list doesn’t happen.

Writing the check as one value

The check becomes a type, a reducer that moves it, and a component that renders from it. The type and the reducer live in verification.ts.

The states, and what each carries

Each state is a member of one union, tagged by status. Look at where the data sits:

export type Verification =
  | { status: "idle"; attempt: number }
  | { status: "checking"; attempt: number }
  | { status: "verified"; attempt: number; last4: string }
  | { status: "failed"; attempt: number; reason: string };

export const INITIAL: Verification = { status: "idle", attempt: 0 };

reason exists only on a failure and last4 only on a success, so the type won’t let the page read an error message while it is checking. attempt counts the checks started, which the next step relies on.

The moves, in one place

A reducer, read with useReducer, decides which moves are allowed. Two rules matter:

export type VerificationEvent =
  | { type: "check"; attempt: number }
  | { type: "succeeded"; attempt: number; last4: string }
  | { type: "failed"; attempt: number; reason: string }
  | { type: "edited" };

export function verify(
  state: Verification,
  event: VerificationEvent,
): Verification {
  switch (event.type) {
    case "check":
      // A second click while checking changes nothing.
      return state.status === "checking"
        ? state
        : { status: "checking", attempt: event.attempt };
    case "succeeded":
    case "failed": {
      // An answer to an earlier attempt, or after an edit, is
      // dropped.
      if (
        state.status !== "checking" ||
        state.attempt !== event.attempt
      )
        return state;
      return event.type === "succeeded"
        ? {
            status: "verified",
            attempt: event.attempt,
            last4: event.last4,
          }
        : {
            status: "failed",
            attempt: event.attempt,
            reason: event.reason,
          };
    }
    case "edited":
      // A different IBAN: whatever was known about the old one no
      // longer applies.
      return state.status === "idle"
        ? state
        : { status: "idle", attempt: state.attempt };
  }
}

A click while checking returns the same state, so nothing re-renders; the guard in the handler is what keeps a second request from going out. An answer is accepted only while checking and only for the current attempt, so a bank reply that arrives after the IBAN was edited can’t mark the new IBAN as verified.

Sending the check

The handler numbers the attempt and reports the answer under that number:

async function handleCheck() {
  if (state.status === "checking") return;
  const attempt = state.attempt + 1;
  dispatch({ type: "check", attempt });
  try {
    const { last4 } = await checkAccount(iban);
    dispatch({ type: "succeeded", attempt, last4 });
  } catch {
    const reason =
      "The bank didn’t recognise this IBAN. Check and try again.";
    dispatch({ type: "failed", attempt, reason });
  }
}

Rendering from the status

The button’s label is a record keyed by status, so TypeScript requires a label for every state, and the status line is a switch over the same union:

"use client";
// Payout settings: verify the bank account payouts go to. The check
// is one value, and only the reducer in verification.ts moves it.
import { useId, useReducer, useState } from "react";
import { INITIAL, verify } from "./verification";
import type { Verification } from "./verification";

export type CheckAccount = (
  iban: string,
) => Promise<{ last4: string }>;

const BUTTON: Record<Verification["status"], string> = {
  idle: "Verify account",
  checking: "Checking…",
  verified: "Verify again",
  failed: "Try again",
};

function statusText(state: Verification): string {
  switch (state.status) {
    case "idle":
    case "failed":
      return "";
    case "checking":
      return "Checking the account with your bank…";
    case "verified":
      return `Verified: payouts go to account ending ${state.last4}.`;
  }
}

export function BankAccount({
  checkAccount,
}: {
  checkAccount: CheckAccount;
}) {
  const id = useId();
  const [iban, setIban] = useState("");
  const [state, dispatch] = useReducer(verify, INITIAL);

  async function handleCheck() {
    if (state.status === "checking") return;
    const attempt = state.attempt + 1;
    dispatch({ type: "check", attempt });
    try {
      const { last4 } = await checkAccount(iban);
      dispatch({ type: "succeeded", attempt, last4 });
    } catch {
      const reason =
        "The bank didn’t recognise this IBAN. Check and try again.";
      dispatch({ type: "failed", attempt, reason });
    }
  }

  return (
    <section aria-labelledby={`${id}-title`}>
      <h2 id={`${id}-title`}>Payout account</h2>
      <label htmlFor={`${id}-iban`}>IBAN</label>
      <input
        id={`${id}-iban`}
        value={iban}
        autoComplete="off"
        spellCheck={false}
        onChange={(event) => {
          setIban(event.target.value);
          dispatch({ type: "edited" });
        }}
      />
      {/* aria-disabled, not disabled: focus stays while checking. */}
      <button
        type="button"
        aria-disabled={state.status === "checking"}
        onClick={handleCheck}
      >
        {BUTTON[state.status]}
      </button>
      <p role="status">{statusText(state)}</p>
      {state.status === "failed" ? (
        <p role="alert">{state.reason}</p>
      ) : null}
    </section>
  );
}

Press “Verify account” and the button reads “Checking…” while a status line says the bank is being asked. A refusal replaces both with an alert that says what to check, and the button offers “Try again”. Retry, and the alert goes the moment checking starts. Edit the IBAN while a check is out, and its answer is dropped:

An answer that arrives after an edit
MomentThe statusOn screen
“Verify account” pressedchecking, attempt 1Checking…
The IBAN is editedidle, attempt 1Verify account
The bank says yes to attempt 1idle, attempt 1Verify account

Living with one status

A status makes the states explicit, and most of the remaining work is in their edges.

A union, a reducer, or a state chart

For a straight line of states, a union in useState is enough: one setter, one value. A reducer earns its place once there are rules about which moves are allowed, as here. When states nest, such as a check that can also wait for a manual review with its own steps, a state chart library describes the structure better than a hand-written switch.

A new state, found everywhere it matters

Add a fifth state, such as a check the bank has sent for manual review, and TypeScript lists every place that has to decide what it looks like. The record of button labels and the status line’s switch both stop compiling until the new state has an answer. The flags had no such list. A new flag compiled everywhere, and the screens it broke were found by customers.

Answers that arrive late

The attempt number keeps the screen right when answers arrive out of order. It doesn’t stop the request. Pass an AbortSignal to the request as well, aborted on an edit, so the browser stops waiting for an answer nobody will read.

Messages that match the state

Each state has its own words. Checking and verified are read politely from a status region; a failure is an alert, read at once, and says what to check rather than only that it failed. The button uses aria-disabled while checking, not disabled, because a focused button that becomes disabled loses its focus, and a keyboard or screen reader user loses their place with it.

Testing the rules without a screen

The reducer is a pure function, so its rules can be tested with plain values, and the component test only has to prove the screen follows. A bank the test answers by hand holds the checking state open for as long as the assertions need:

import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test } from "vitest";
import { BankAccount } from "./bank-account";
import { INITIAL, verify } from "./verification";

const IBAN = "DE89 3704 0044 0532 0130 00";

/** A bank the test answers when it chooses. */
function heldBank() {
  let answer = (_ok: boolean) => {};
  const checkAccount = () =>
    new Promise<{ last4: string }>((resolve, reject) => {
      answer = (ok) =>
        ok
          ? resolve({ last4: "3000" })
          : reject(new Error("Unknown IBAN"));
    });
  return { checkAccount, answer: (ok: boolean) => answer(ok) };
}

test("the reducer drops a second check and a late answer", () => {
  const checking = verify(INITIAL, { type: "check", attempt: 1 });
  expect(verify(checking, { type: "check", attempt: 2 })).toBe(
    checking,
  );
  const edited = verify(checking, { type: "edited" });
  expect(
    verify(edited, { type: "succeeded", attempt: 1, last4: "3000" }),
  ).toEqual(edited);
});

test("checking, then a failure, never shows both", async () => {
  const user = userEvent.setup();
  const bank = heldBank();
  render(<BankAccount checkAccount={bank.checkAccount} />);
  await user.type(
    screen.getByRole("textbox", { name: "IBAN" }),
    IBAN,
  );
  await user.click(
    screen.getByRole("button", { name: "Verify account" }),
  );
  expect(screen.getByRole("status")).toHaveTextContent(
    "Checking the account with your bank…",
  );
  expect(screen.queryByRole("alert")).toBeNull();
  await act(async () => bank.answer(false));
  expect(screen.getByRole("alert")).toHaveTextContent(
    "The bank didn’t recognise this IBAN.",
  );
  expect(screen.getByRole("status")).toBeEmptyDOMElement();
  await user.click(screen.getByRole("button", { name: "Try again" }));
  expect(screen.queryByRole("alert")).toBeNull();
  await act(async () => bank.answer(true));
  expect(screen.getByRole("status")).toHaveTextContent(
    "Verified: payouts go to account ending 3000.",
  );
});

test("an answer for an edited IBAN is ignored", async () => {
  const user = userEvent.setup();
  const bank = heldBank();
  render(<BankAccount checkAccount={bank.checkAccount} />);
  const field = screen.getByRole("textbox", { name: "IBAN" });
  await user.type(field, IBAN);
  await user.click(
    screen.getByRole("button", { name: "Verify account" }),
  );
  await user.type(field, "1");
  await act(async () => bank.answer(true));
  expect(screen.getByRole("status")).toBeEmptyDOMElement();
  expect(
    screen.getByRole("button", { name: "Verify account" }),
  ).toBeVisible();
});

All three pass. With the three flags, the retry in the second test would still find the old alert on screen.

One status from the server

When the check runs in a Server Function, return the same kind of union, { ok: true, last4 } or { ok: false, reason }. Plain objects cross the boundary as they are, and the client maps each one to a single event.

React 19 keeps the pending part for you

useActionState returns the last result with an isPending flag React maintains itself. The status then becomes a derived value, isPending ? "checking" : result.status, and the result type is the union above without its checking member. It queues a second submission and runs it after the first, so a double click still checks twice unless the action skips a repeat. The React Compiler changes nothing here; the pattern is about which values exist, not how often they are computed.

When separate booleans are right

Flags that describe independent facts stay separate. Whether a panel is expanded and whether its sound is muted can be true in any combination, and every combination is a real screen. Merging them into one status would create states such as “expanded and muted” for no gain.

A single boolean for a process with two states is a status already. Reach for the union when a third state arrives, or when two flags start needing to be set together.

How Turtlekit’s Button takes one status

Button takes a single status of idle, pending, success or error, and draws the spinner, the check and the labels from it. The page maps its own union onto it, and shows a failure in a Callout with announce:

// The check's actions with Turtlekit: the page's one status maps onto
// Button's status, and a failure is a Callout that announces itself.
import { Button, Callout, Stack } from "turtlekit";
import type { Verification } from "./verification";

const BUTTON_STATUS = {
  idle: "idle",
  checking: "pending",
  verified: "success",
  failed: "error",
} as const;

export function VerifyAccount({
  state,
  onCheck,
}: {
  state: Verification;
  onCheck: () => void;
}) {
  return (
    <Stack gap="items">
      <Button
        status={BUTTON_STATUS[state.status]}
        pendingLabel="Checking…"
        successLabel="Verified"
        errorLabel="Try again"
        onClick={onCheck}
      >
        Verify account
      </Button>
      {state.status === "failed" ? (
        <Callout
          tone="danger"
          title="We couldn’t verify this account"
          announce
        >
          {state.reason}
        </Callout>
      ) : null}
    </Stack>
  );
}

While pending, the button keeps its focus and ignores clicks, and its label stays announced. When nothing else on the page needs the status, AsyncButton runs the action and keeps the status itself.

Built on this pattern in Turtlekit: Button, Code block, Icon button and Newsletter signup.

When the states blur

These three symptoms point back to a status that isn’t quite one value yet.

A spinner and an error show together

Two flags describe one process. Replace them with one status, and make starting a check move to the checking state, which has no error to show.

An old answer overwrites a newer one

The reducer accepts answers without asking which attempt they belong to. Tag each request and drop answers whose attempt is no longer current.

TypeScript says reason doesn’t exist on the state

The code reads reason before narrowing. Check state.status === "failed" first; inside that branch the field exists.

Name the states first

Most bugs in an async screen are not in the request. They are in the screen’s idea of where the request is, spread across flags that each tell part of the story. Writing the states down as one type is a design decision as much as a coding one. It asks what the screen shows in each, including the ones nobody drew, such as an answer that arrives after the account was edited. The type then keeps that answer long after the conversation where it was decided.

State colocation in React

When the status belongs to one component and nothing above it should hold it.

Lifting state up in React

When a summary elsewhere on the page has to show the same status.

Deriving state during render

When “is checking” or “can retry” can be computed from the status instead of stored.

Adjusting state when a prop changes

When the account shown changes and its status has to start over.