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.

Loading components on demand in React

Turtlekit Editorial Team

Sales reps check an invoice on their phones minutes before a call, often on a hotel’s Wi-Fi or a train’s. For them the invoice page had grown slow to open, although the page itself had barely changed in a year. What had changed was the “Show PDF preview” button near the top.

The preview drew the invoice exactly as it would print, with the PDF renderer and the invoice fonts behind it. The page imported it at the top of its file, so every visit downloaded all of that code before the page could respond, whether or not anyone pressed the button. Most visits never did.

Loading components on demand moves code like that out of the first download. The page declares the preview with lazy, its code is fetched the first time React renders it, and a Suspense boundary decides what shows while it arrives.

Code everyone downloads for a button few press

The page as it was, at its first lines:

// Avoid: a static import puts the preview in the page's own bundle
import InvoicePreview from "./invoice-preview";

A static import tells the bundler that this module is needed before the page can run. The bundler obliges by putting the preview, and everything it imports, in the same download as the page. The preview sits behind a button, so its cost is paid on every visit and repaid only on the visits that open it.

Nothing in the page’s code shows the cost. It shows in the build output, where the page’s file carries the renderer, and in the browser’s Coverage panel, which reports how much of the downloaded code a visit ran.

Split where people decide

Think of the page as rooms behind doors. The first download is the room people walk into; a split point is a door, and the code behind it is fetched when someone opens it. The best place for a door is where people make a choice: a button, a tab, a dialog, a route.

InvoicePagedownloads with the pageimport()on hover, focus or clickInvoicePreviewwith the PDF renderer, in its own file
The page’s first download stops at the button. The preview and its renderer are a separate file, fetched when someone reaches for the button.

Split at the moments people choose, not along the files you happened to write. Two React pieces make the door. lazy turns a function that imports a module into a component that loads itself on first render. Suspense is the frame around it: while the code is on its way, the boundary decides what shows instead.

Moving the preview behind its button

Four changes: a lazy component, a page that holds still while it loads, a download that starts early, and a message for when it never arrives.

A lazy component, declared once

// Declared once, at the top of the module: the import runs on first
// use.
const loadPreview = () => import("./invoice-preview");
const InvoicePreview = lazy(loadPreview);

/**
 * Starts the download early. A failure here is left to the render to
 * show.
 */
function preloadPreview() {
  loadPreview().catch(() => {});
}

loadPreview is the dynamic import() the bundler sees as a split point. lazy calls it the first time InvoicePreview renders, then caches the promise and the module, so the code is fetched once per visit. The module must export the component as its default.

The same function serves to preload. Calling it early starts the download, and the browser evaluates a module once, so the import lazy makes later receives the same module rather than a second copy.

Pitfall

Declare lazy components at the top level of a module, never inside another component. A lazy component created during render is a new component every time, so on each render React resets its state and it suspends again, as react.dev’s lazy reference warns.

Keeping the page in place while the code loads

const [showPreview, setShowPreview] = useState(false);
const [loading, startLoading] = useTransition();

function togglePreview() {
  // In a Transition, React keeps the page on screen while the code
  // loads.
  startLoading(() => setShowPreview(!showPreview));
}

Opening the preview is wrapped in a Transition. The Suspense boundary is already on the page, showing nothing, so the preview counts as an update to content already revealed. react.dev’s Suspense reference describes what follows: instead of a fallback, React keeps the page as it was and reports the Transition as pending. The button says it is busy with aria-disabled, which keeps it focusable, and a status line, rendered empty from the start, announces “Loading the preview…”.

The boundary keeps a fallback for an update that isn’t a Transition. A boundary that mounts together with its lazy component shows the fallback straight away; the Turtlekit version below works that way, inside a dialog.

Starting the download before the click

The button calls preloadPreview when a pointer moves onto it or keyboard focus reaches it. With a mouse, the pointer usually arrives a moment before the click, a head start the download can often use. On a touch screen the touch that enters the button is the one that presses it, so the head start is close to nothing; for a part phone users open often, start the import once the page has loaded instead. A failure here is ignored on purpose: if the code still can’t load when the preview renders, the error boundary says so.

Saying so when the code doesn’t arrive

"use client";
// What shows in place of the preview when its code can't be
// downloaded: on a lost connection, or in a tab opened before a
// release replaced the files.
import { Component } from "react";
import type { ReactNode } from "react";

interface State {
  failed: boolean;
}

export class PreviewErrorBoundary extends Component<
  { children: ReactNode },
  State
> {
  override state: State = { failed: false };

  static getDerivedStateFromError(): State {
    return { failed: true };
  }

  override render() {
    if (this.state.failed) {
      return (
        <p role="alert">
          The preview couldn’t load. Reload the page to try again.
        </p>
      );
    }
    return this.props.children;
  }
}

A dynamic import fails like any request: on a dropped connection, or in a tab opened before a release replaced the files it points to. lazy throws the failure to the nearest error boundary, which React 19 still writes as a class component. Reloading is right in both cases, because it fetches the files the current release serves.

The finished page

"use client";
// The invoice page. The PDF preview's code downloads the first time
// someone shows an interest in it, and the page stays as it is while
// it arrives.
import {
  Suspense,
  lazy,
  useId,
  useState,
  useTransition,
} from "react";
import { PreviewErrorBoundary } from "./preview-error-boundary";
import type { Invoice } from "./invoice";

// Declared once, at the top of the module: the import runs on first
// use.
const loadPreview = () => import("./invoice-preview");
const InvoicePreview = lazy(loadPreview);

/**
 * Starts the download early. A failure here is left to the render to
 * show.
 */
function preloadPreview() {
  loadPreview().catch(() => {});
}

export function InvoicePage({ invoice }: { invoice: Invoice }) {
  const id = useId();
  const [showPreview, setShowPreview] = useState(false);
  const [loading, startLoading] = useTransition();

  function togglePreview() {
    // In a Transition, React keeps the page on screen while the code
    // loads.
    startLoading(() => setShowPreview(!showPreview));
  }

  return (
    <main>
      <h1>{invoice.id}</h1>
      <p>
        {invoice.client}, due {invoice.due}: {invoice.total}
      </p>
      {/* Pointing at or focusing the button starts the download. */}
      <button
        type="button"
        aria-expanded={showPreview}
        aria-controls={`${id}-preview`}
        aria-disabled={loading}
        onClick={loading ? undefined : togglePreview}
        onPointerEnter={preloadPreview}
        onFocus={preloadPreview}
      >
        {showPreview ? "Hide PDF preview" : "Show PDF preview"}
      </button>
      <p role="status">{loading ? "Loading the preview…" : ""}</p>
      <div id={`${id}-preview`}>
        <PreviewErrorBoundary>
          <Suspense fallback={<p>Loading the preview…</p>}>
            {showPreview ? (
              <InvoicePreview invoice={invoice} />
            ) : null}
          </Suspense>
        </PreviewErrorBoundary>
      </div>
    </main>
  );
}

Open the page and none of the preview’s code loads. Point at the button and the download starts; press it and the page stays as it is, the button marked busy, until the preview appears under it. Hide it and show it again, and it appears at once from the cached module.

What a split point costs

A split moves code out of the first download, and the code has to come from somewhere later.

A split adds a request

The code behind a door is fetched when the door opens, so the first open pays for a round trip that a bundled component wouldn’t. Preloading on intent hides most of it. For a split the page will almost certainly need, such as the next step of a checkout, start the import once the page has loaded rather than waiting for intent.

Measuring the split

Confirm the split in the build output, which lists the preview as a file of its own, and in the browser’s Network panel, where that file appears only once the pointer reaches the button. The test measures the same contract in code, by counting how many times the preview module loads:

import { Suspense, lazy } from "react";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { InvoicePage } from "./invoice-page";
import { PreviewErrorBoundary } from "./preview-error-boundary";
import type { Invoice } from "./invoice";

// The preview module counts each time it is loaded, and arrives when
// the test says so, as a download on a slow connection would.
const download = vi.hoisted(() => {
  let arrive = () => {};
  const arrived = new Promise<void>((resolve) => (arrive = resolve));
  return { count: 0, arrived, arrive: () => arrive() };
});
vi.mock("./invoice-preview", async (original) => {
  download.count += 1;
  await download.arrived;
  return original();
});

// A lazy component whose download fails, as it does offline or after
// a release.
const UnreachablePreview = lazy(() =>
  Promise.reject(new Error("Failed to fetch module")),
);

const INVOICE: Invoice = {
  id: "INV-0042",
  client: "Brightside Studio",
  issued: "1 Nov 2026",
  due: "15 Nov 2026",
  lines: [
    { description: "Design retainer, November", amount: "€1,800.00" },
    { description: "Brand workshop", amount: "€600.00" },
  ],
  total: "€2,400.00",
};

test("the preview loads once, on intent; page stays", async () => {
  const user = userEvent.setup();
  render(<InvoicePage invoice={INVOICE} />);
  const toggle = screen.getByRole("button", {
    name: "Show PDF preview",
  });
  expect(download.count).toBe(0);

  await user.hover(toggle);
  expect(download.count).toBe(1);

  await user.click(toggle);
  // The code is still on its way: the page keeps its state and says
  // so.
  expect(screen.getByRole("status")).toHaveTextContent(
    "Loading the preview…",
  );
  expect(toggle).toHaveAttribute("aria-expanded", "false");
  expect(toggle).toHaveAttribute("aria-disabled", "true");

  await act(async () => download.arrive());
  expect(
    await screen.findByRole("figure", {
      name: "PDF preview of INV-0042",
    }),
  ).toBeVisible();
  expect(toggle).toHaveAccessibleName("Hide PDF preview");
  expect(toggle).toHaveAttribute("aria-expanded", "true");
  expect(screen.getByRole("status")).toBeEmptyDOMElement();

  // Closed and opened again, the preview shows at once from the
  // cached code.
  await user.click(toggle);
  await user.click(toggle);
  expect(
    screen.getByRole("figure", { name: "PDF preview of INV-0042" }),
  ).toBeVisible();
  expect(download.count).toBe(1);
});

test("a failed download says what to do instead", async () => {
  vi.spyOn(console, "error").mockImplementation(() => {});
  render(
    <PreviewErrorBoundary>
      <Suspense fallback={<p>Loading the preview…</p>}>
        <UnreachablePreview />
      </Suspense>
    </PreviewErrorBoundary>,
  );
  expect(await screen.findByRole("alert")).toHaveTextContent(
    "The preview couldn’t load. Reload the page to try again.",
  );
});

The module arrives only when the test says so, as a slow network would deliver it. Before any interaction the count is 0; hovering makes it 1; pressing the button shows the status line while the page holds still; closing and opening again leaves it at 1. A lazy component whose download fails shows the alert in its place.

Focus stays where it was

Focus stays on the button throughout, because nothing on the page changes until the preview is ready. The button controls the preview through aria-expanded and aria-controls, so a screen reader hears it as expanded once the preview is there, and the status line covers the wait in between.

Server rendering and Server Components

A component behind a button isn’t in the server’s HTML, because it doesn’t render until someone asks for it. A lazy component that does render on the server waits inside its Suspense boundary, and with streaming, React can send the fallback first and the content when it is ready. Frameworks built on Server Components split client code by route on their own; lazy stays the tool for a part of a page that waits for a click.

React 18, 19 and the Compiler

lazy and Suspense work the same way on both versions. React 19 changed one detail, described in its upgrade guide. When a component suspends, React 19 commits the nearest fallback at once, then renders the suspended siblings to start their downloads early; React 18 rendered the siblings first. The React Compiler memoises; it doesn’t split code, so where the doors go stays your decision.

When loading later costs more

Split code that is large, used on some visits only, and behind a choice. Three kinds of component belong in the first download. Anything visible when the page opens: loading it lazily puts the content people came for behind a fallback, and the page is complete later than it would have been. Small components, whose request costs more than the bytes it saves. And anything nearly every visit uses, where a split only adds a round trip to each of them.

Routes are the clearest split points, and most routers and frameworks already split by route. Start there, and add a lazy component inside a page only where the build output shows a heavy dependency behind a choice.

How a Turtlekit Dialog loads its content on demand

In Turtlekit, a preview like this opens in a Dialog. The Dialog keeps its body mounted while it is closed, so the lazy part renders only while it is open. It sits inside a boundary of its own, whose fallback is SkeletonText, lines in the shape of the page on its way:

"use client";
// The PDF preview with Turtlekit: a Dialog whose body loads on first
// use, with placeholder lines the shape of the page while the code
// arrives.
import { Suspense, lazy, useState } from "react";
import {
  Button,
  Callout,
  Dialog,
  ErrorBoundary,
  SkeletonText,
  VisuallyHidden,
} from "turtlekit";
import type { Invoice } from "./invoice";

const loadPreview = () => import("./invoice-preview");
const InvoicePreview = lazy(loadPreview);

function preloadPreview() {
  loadPreview().catch(() => {});
}

export function PreviewButton({ invoice }: { invoice: Invoice }) {
  const [open, setOpen] = useState(false);
  return (
    <>
      <Button
        variant="surface"
        onClick={() => setOpen(true)}
        onPointerEnter={preloadPreview}
        onFocus={preloadPreview}
      >
        Preview PDF
      </Button>
      <Dialog
        open={open}
        onClose={() => setOpen(false)}
        title={`Invoice ${invoice.id}`}
        maxWidth="42rem"
      >
        {/* The body stays mounted while closed: render on open. */}
        {open ? (
          <ErrorBoundary
            fallback={() => (
              <Callout
                tone="danger"
                title="The preview couldn’t load"
              >
                Reload the page to try again.
              </Callout>
            )}
          >
            <Suspense
              fallback={
                <>
                  <VisuallyHidden>
                    Loading the preview…
                  </VisuallyHidden>
                  <SkeletonText lines={8} />
                </>
              }
            >
              <InvoicePreview invoice={invoice} />
            </Suspense>
          </ErrorBoundary>
        ) : null}
      </Dialog>
    </>
  );
}

The Dialog moves focus inside when it opens and returns it to the button when it closes, whether the preview arrived or not. If the download fails, the ErrorBoundary shows a danger Callout in the dialog saying what to do, and the rest of the invoice page carries on.

When on-demand loading misbehaves

The whole page turns into a spinner

The nearest Suspense boundary is above the page, and the update wasn’t a Transition. Put a boundary close to the lazy component, and open it inside startTransition, so content already on screen stays.

“Element type is invalid” after the change

The module has a named export where lazy expects a default. Export the component as the default, or map it: lazy(() => import("./invoice-preview").then((m) => ({ default: m.InvoicePreview }))).

People see a loading error after a release

Their tab still points at the previous release’s files, which the new release removed. Keep the previous release’s files for a while after each deploy, and let the error boundary offer the reload.

Pay for what people open

The first invoice page asked every visitor to pay for a feature most of them never used, and nobody could see the bill: it was a line in the build output, not in the code. Moving the preview behind its button makes each visit pay for what it opens. The finished page goes one step further and starts the download the moment someone reaches for the button, so the wait, where there is one, is a single file’s rather than the whole page’s.

useTransition and Actions in React

When an update should keep the current screen while the next one loads.

Error boundaries in React

When a section that fails should say so without taking the page down.

Virtualising long lists in React

When the page is slow because it renders too much, not because it downloads too much.

useDeferredValue in React

When the slow part is a render that typing keeps interrupting.