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.

Guard clauses and early returns in React

Turtlekit Editorial Team

An accountant opened an invoice from last March to answer a client’s question about a late fee, and the page went blank. Not the panel beside the invoice: the whole page, its header and navigation with it. The customer on that invoice had been deleted in a clean-up of duplicate records, and the customer panel still expected one.

The panel handled loading and errors in one nested expression, three levels deep before it reached the customer’s name. A deleted customer came back from the API as null, a case the expression had no branch for, so it fell through to the happy path and read a name from nothing. With no error boundary above it, React removed the page.

Guard clauses give each of those cases a line of its own. A guard clause is an early return at the top of a component. It checks one case that ends the render and returns what that case shows. The rest of the function is the happy path, where every case it handled is already gone.

Every case in one expression

Here is the panel as it shipped, at its render:

// Avoid: TypeScript can't tie customer to the flags, so it asserts
const shown = customer as Customer;
return (
  <section aria-label="Customer">
    {isLoading ? (
      <p>Loading…</p>
    ) : error ? (
      <p role="alert">
        Something went wrong.{" "}
        <button type="button" onClick={onRetry}>
          Retry
        </button>
      </p>
    ) : (
      <>
        <h2>{shown.name}</h2>
        {shown.archivedAt ? <p>Archived</p> : null}
        <a href={`mailto:${shown.email}`}>{shown.email}</a>
      </>
    )}
  </section>
);

TypeScript can’t connect customer to isLoading and error. They are three separate values, so in the last branch the customer is still Customer | null | undefined, and the code asserts otherwise to compile. The assertion is where the deleted customer got through: the type said a customer was there, and the data didn’t.

The missing case is hard to add, too. It needs a branch at the right depth, with its brackets matched, and the next case after it, an archived customer or one the user isn’t allowed to see, makes the nesting deeper. The happy path, the part people come for, sits at the bottom of all of it.

Turn the cases away at the door

Think of the component as a door with a short checklist. Each case that can’t go further is answered at the door with what it needs: an alert, a sentence, nothing at all. Whoever is still standing after the last check is known to be fine, and the rest of the function can stop asking.

errorthe alertcustomer === undefinednullcustomer === nullthe notecustomer: Customerhappy path
Each guard asks one question and either ends the render or lets it through. Past the last one, the type has no undefined or null left in it.

Handle the cases that end the render first; what’s left is the screen you meant to build. TypeScript follows the same path, so each return removes a possibility from the type, and the happy path reads a Customer without an assertion.

Writing the cases as returns

The panel splits in two: a frame that is the same in every state, and a summary inside it that returns early.

A frame that stays

export function CustomerPanel({
  query,
  onRetry,
}: {
  query: CustomerQuery;
  onRetry: () => void;
}) {
  const id = useId();
  const heading = useRef<HTMLHeadingElement>(null);
  const loading = query.customer === undefined && !query.error;
  function retry() {
    // The button is about to go: keep focus in the panel.
    heading.current?.focus();
    onRetry();
  }
  return (
    <section aria-labelledby={id}>
      <h2 id={id} ref={heading} tabIndex={-1}>
        Customer
      </h2>
      <p role="status">{loading ? "Loading the customer…" : ""}</p>
      <CustomerSummary {...query} onRetry={retry} />
    </section>
  );
}

The frame holds what is true whatever the request is doing: the section, its heading and a status line. Loading is derived from the query rather than passed as a flag of its own. Because the guards return from the component inside, the heading and the status region never leave the page, which matters for the retry further down.

One case per line

function CustomerSummary({
  customer,
  error,
  onRetry,
}: CustomerQuery & { onRetry: () => void }) {
  // Every hook comes before the first return.
  const [notesOpen, setNotesOpen] = useState(false);
  const notesId = useId();

  if (error) {
    return (
      <div role="alert">
        <p>
          The customer couldn’t load. Check your connection and try
          again.
        </p>
        <button type="button" onClick={onRetry}>
          Try again
        </button>
      </div>
    );
  }
  // Still on its way: the frame says so.
  if (customer === undefined) return null;
  if (customer === null) {
    return (
      <p>
        This customer was deleted. The invoice keeps the address it
        was sent to.
      </p>
    );
  }

The order is deliberate. A failed request has no customer to show, so it goes first. An undefined customer is still on its way, and the frame already says so, so the summary returns null. A null customer was deleted, and the panel says that much while the invoice keeps the address it was sent to.

Pitfall

Every hook goes above the first return. React matches hooks by the order they are called, so a useState below a guard runs on some renders and not on others, and React stops with “Rendered fewer hooks than expected”. The Rules of Hooks list it plainly: do not call hooks after a conditional return. use is the exception, since it isn’t a hook; reading context with use() shows it after a guard.

The happy path

// From here on, customer is a Customer.
return (
  <>
    <h3>{customer.name}</h3>
    {customer.archivedAt ? (
      <p>Archived on {customer.archivedAt}.</p>
    ) : null}
    <a href={`mailto:${customer.email}`}>{customer.email}</a>
    <button
      type="button"
      aria-expanded={notesOpen}
      aria-controls={notesId}
      onClick={() => setNotesOpen(!notesOpen)}
    >
      Team notes
    </button>
    <p id={notesId} hidden={!notesOpen}>
      {customer.notes}
    </p>
  </>
);

By this line the type of customer is Customer: both returns above took undefined and null out of it. An archived customer is not a guard. They still have a name and an email to show, so the happy path gives them one conditional line. A guard is for a case that replaces the content, not one that adds to it.

The finished panel

"use client";
// The customer panel beside an invoice. The frame stays in every
// state; the summary inside it returns early for each case that ends
// its render.
import { useId, useRef, useState } from "react";
import type { CustomerQuery } from "./customer";

export function CustomerPanel({
  query,
  onRetry,
}: {
  query: CustomerQuery;
  onRetry: () => void;
}) {
  const id = useId();
  const heading = useRef<HTMLHeadingElement>(null);
  const loading = query.customer === undefined && !query.error;
  function retry() {
    // The button is about to go: keep focus in the panel.
    heading.current?.focus();
    onRetry();
  }
  return (
    <section aria-labelledby={id}>
      <h2 id={id} ref={heading} tabIndex={-1}>
        Customer
      </h2>
      <p role="status">{loading ? "Loading the customer…" : ""}</p>
      <CustomerSummary {...query} onRetry={retry} />
    </section>
  );
}

function CustomerSummary({
  customer,
  error,
  onRetry,
}: CustomerQuery & { onRetry: () => void }) {
  // Every hook comes before the first return.
  const [notesOpen, setNotesOpen] = useState(false);
  const notesId = useId();

  if (error) {
    return (
      <div role="alert">
        <p>
          The customer couldn’t load. Check your connection and try
          again.
        </p>
        <button type="button" onClick={onRetry}>
          Try again
        </button>
      </div>
    );
  }
  // Still on its way: the frame says so.
  if (customer === undefined) return null;
  if (customer === null) {
    return (
      <p>
        This customer was deleted. The invoice keeps the address it
        was sent to.
      </p>
    );
  }

  // From here on, customer is a Customer.
  return (
    <>
      <h3>{customer.name}</h3>
      {customer.archivedAt ? (
        <p>Archived on {customer.archivedAt}.</p>
      ) : null}
      <a href={`mailto:${customer.email}`}>{customer.email}</a>
      <button
        type="button"
        aria-expanded={notesOpen}
        aria-controls={notesId}
        onClick={() => setNotesOpen(!notesOpen)}
      >
        Team notes
      </button>
      <p id={notesId} hidden={!notesOpen}>
        {customer.notes}
      </p>
    </>
  );
}

Open an invoice whose customer is still loading, and the panel shows its heading with “Loading the customer…”. A failed request shows an alert with “Try again”; pressing it moves focus to the panel’s heading while the request starts again. A deleted customer gets a sentence in place of a crash, and a customer who loads shows their name, email and the team’s notes behind a disclosure button.

What an early return changes

Returns move the cases out of the markup. Most of what follows is about what that move makes visible.

The order of the guards is a product decision

With the error first, a refresh that fails hides a customer that was on screen a moment ago. That can be right for a payment screen and too harsh for a side panel. If the panel should keep the last customer it had and add a note, check customer before error and show the failure beside the data. With guards, that decision is the order of two lines; in the nested version it was a rewrite. Rendering partial data takes the idea further.

Guards read best on values that narrow

A guard on customer === null narrows the value the happy path uses. A guard on a flag such as isLoading narrows nothing, and a chain of flag checks is usually several booleans describing one process. When the guards start testing flags in combination, they want one status instead of many booleans, a union the guards can narrow by its tag.

Accessibility: the frame keeps people’s place

A live region is read when its content changes, so it has to be on the page before the change. The status line lives in the frame, which never unmounts, and on a retry it changes to “Loading the customer…”, which a screen reader reads politely. The error is an alert that names what failed and what to do.

The retry is where guards can cost a keyboard user their place. “Try again” lives in the error branch, and the next render replaces that branch, so the focused button disappears and focus falls back to the page itself. The handler moves focus to the panel’s heading first, which has tabIndex={-1} so a script can focus it without adding it to the tab order. People land on “Customer” while the status line says what is happening.

Testing each case by role

Each guard is a case a test can reach through the screen alone. A small harness holds the query, so the test can fail it, retry it and answer it in one panel:

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { expect, test } from "vitest";
import { CustomerPanel } from "./customer-panel";
import type { Customer, CustomerQuery } from "./customer";

const BRIGHTSIDE: Customer = {
  id: "cus_1042",
  name: "Brightside Studio",
  email: "accounts@brightside.example",
  archivedAt: null,
  notes: "Pays by bank transfer, usually a week after the due date.",
};

/** The panel with a query the test settles and retries. */
function Harness({ first }: { first: CustomerQuery }) {
  const [query, setQuery] = useState(first);
  return (
    <>
      <CustomerPanel
        query={query}
        onRetry={() => setQuery({ customer: undefined, error: null })}
      />
      <button
        type="button"
        onClick={() =>
          setQuery({ customer: BRIGHTSIDE, error: null })
        }
      >
        Answer
      </button>
    </>
  );
}

test("each case returns early, in a frame that stays", async () => {
  const user = userEvent.setup();
  render(
    <Harness
      first={{ customer: undefined, error: new Error("offline") }}
    />,
  );
  expect(
    screen.getByRole("region", { name: "Customer" }),
  ).toBeVisible();
  expect(screen.getByRole("alert")).toHaveTextContent(
    "The customer couldn’t load.",
  );

  await user.click(screen.getByRole("button", { name: "Try again" }));
  // The button went with the error; focus stayed in the panel, on its
  // heading.
  expect(
    screen.getByRole("heading", { level: 2, name: "Customer" }),
  ).toHaveFocus();
  expect(screen.getByRole("status")).toHaveTextContent(
    "Loading the customer…",
  );
  expect(screen.queryByRole("alert")).toBeNull();

  await user.click(screen.getByRole("button", { name: "Answer" }));
  expect(
    screen.getByRole("heading", {
      level: 3,
      name: "Brightside Studio",
    }),
  ).toBeVisible();
  expect(screen.getByRole("status")).toBeEmptyDOMElement();
  const notes = screen.getByRole("button", { name: "Team notes" });
  expect(notes).toHaveAttribute("aria-expanded", "false");
  await user.click(notes);
  expect(screen.getByText(/Pays by bank transfer/)).toBeVisible();
});

test("a deleted customer gets a sentence, not a crash", () => {
  render(<Harness first={{ customer: null, error: null }} />);
  expect(screen.getByText(/This customer was deleted/)).toBeVisible();
  expect(screen.queryByRole("heading", { level: 3 })).toBeNull();
  expect(screen.getByRole("status")).toBeEmptyDOMElement();
});

Both pass. Against the nested version, the second test would throw on the deleted customer, and the first would find focus on the page instead of the heading after the retry.

Server Components move two of the guards

An async Server Component awaits its data before it renders, so it has no loading case of its own. The wait belongs to the Suspense boundary above it, and a thrown error belongs to an error boundary or the framework’s error page. What remains are guards about the data itself: the customer is missing, archived, or not one this person may see. Those read the same on the server, and an early return reads as plainly there.

React 19 and the Compiler

React 19’s use reads a promise inside a Suspense boundary, which takes the loading and error guards out of the component in a client app too. Unlike a hook, it may also be called after an early return. The React Compiler doesn’t change where returns go. It relies on the same Rules of Hooks, so the hooks still come before the first guard.

When a ternary is enough

A guard ends the render, so it suits cases that replace the content. A small choice inside the markup, such as a “Paid” badge or nothing, reads better where it sits: {invoice.paid ? <PaidBadge /> : null}. Splitting a component to return early for a badge would scatter one screen across two places for no gain.

The same goes for cases that add to the content, like the archived note in the panel. And when the guards pile up, each checking a flag, the component has outgrown them: model the states as one value, then guard on that.

How Turtlekit’s ErrorSummary holds its own guard

A guard can also live inside a component, so the page that uses it never writes one. ErrorSummary returns nothing while its list of errors is empty, so a form renders it unconditionally, and it appears only after a submit that failed:

"use client";
// A new customer with Turtlekit: ErrorSummary holds its own guard, so
// the form renders it unconditionally and it shows only after a
// failed submit.
import { useState } from "react";
import { Button, ErrorSummary, Stack, TextField } from "turtlekit";
import type { ErrorSummaryItem } from "turtlekit";

function check(data: FormData): ErrorSummaryItem[] {
  const errors: ErrorSummaryItem[] = [];
  if (!String(data.get("name") ?? "").trim())
    errors.push({
      id: "customer-name",
      message: "Enter the customer’s name",
    });
  if (!String(data.get("email") ?? "").includes("@"))
    errors.push({
      id: "customer-email",
      message: "Enter an email for invoices",
    });
  return errors;
}

export function NewCustomer({
  onCreate,
}: {
  onCreate: (data: FormData) => void;
}) {
  const [errors, setErrors] = useState<ErrorSummaryItem[]>([]);
  const messageFor = (id: string) =>
    errors.find((e) => e.id === id)?.message;
  return (
    <Stack
      as="form"
      gap="stack"
      aria-label="New customer"
      noValidate
      onSubmit={(event) => {
        event.preventDefault();
        const data = new FormData(event.currentTarget);
        const found = check(data);
        setErrors(found);
        if (found.length === 0) onCreate(data);
      }}
    >
      <ErrorSummary errors={errors} />
      <TextField
        id="customer-name"
        name="name"
        label="Name"
        error={messageFor("customer-name")}
      />
      <TextField
        id="customer-email"
        name="email"
        type="email"
        label="Email for invoices"
        error={messageFor("customer-email")}
      />
      <Button type="submit">Add customer</Button>
    </Stack>
  );
}

When it appears, it lists every problem with a link to its field and takes focus, so keyboard and screen-reader users start from it. A second failed submit with the same problems takes focus and is read again, with nothing for the form to do. The form stays flat: no branch for “has errors”, because the component that knows when it has nothing to say says nothing.

Built on this pattern in Turtlekit: Error boundary, Error summary and Tabs.

When an early return misbehaves

Three symptoms cover most of the trouble with guards.

React says it rendered fewer hooks than expected

A guard returns before a hook that ran on the previous render. Move every hook above the first return; a value a hook needs from the data can be read after the guards instead.

TypeScript still says the value may be null

The guard checked something other than the value the happy path uses, such as a loading flag. Guard the value itself, customer === null, or narrow a union by its tag.

Focus jumps to the top of the page after “Try again”

The focused button was in the branch the guard replaced. Before the update, move focus to something that stays, such as the section’s heading.

Let the happy path come last

The first panel handled every case anyone had thought of. The one that broke the page was the case nobody wrote down, and the nesting made its absence invisible. Written as returns, each case is a line someone can read, question and move, and the type says when one is missing. In the finished panel, the deleted customer can’t reach customer.name, because by that line the type has no null left in it. The frame around the returns keeps the heading, and the keyboard’s place, through every one of them.

One status instead of many booleans

When the guards test several flags that should have been one value.

Reading context with use() in React 19

When a context read has to come after an early return, where hooks can’t go.

Error boundaries in React

When a case nobody wrote a guard for should cost one section, not the page.

Empty states in React

When the case to handle is a list with nothing in it.