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