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.

Rendering partial data in React

Turtlekit Editorial Team

A client rang about invoice INV-0042 on an afternoon when the payments service was struggling. The accountant opened the invoice to read out the total and the due date, and got an error page. The invoice itself had come back in a fraction of a second. The page was waiting for its payment history, which never came.

The page loaded everything with one Promise.all and showed nothing until all of it had arrived. Further down, the invoice header returned null whenever the client had no logo, and took the invoice number and the client’s name with it. Both were the same decision made too broadly: a missing part removed the parts around it.

Partial rendering is showing the data you have and giving each missing piece a fallback of its own. Only the data a section can’t do without decides whether it renders; everything else falls back, says that it is missing, or leaves its place quietly.

One missing part, a page gone

The page loaded its data like this:

// Avoid: the payment history decides whether the invoice shows at all
export async function loadInvoicePage(id: string) {
  const [invoice, payments] = await Promise.all([
    getInvoice(id),
    getPayments(id),
  ]);
  return { invoice, payments };
}

Promise.all rejects as soon as any of its promises rejects, and when nothing rejects it resolves only with the slowest. Either way, the payment history, the least urgent thing on the page, set the terms for the invoice, the most urgent. Its header had the same shape on a smaller scale:

export function InvoiceHeader({ invoice }: { invoice: Invoice }) {
  // Avoid: no logo, so no number and no client name either
  if (!invoice.client.logoUrl) return null;
  return (
    <header>
      <img src={invoice.client.logoUrl} alt="" />
      <h1>Invoice {invoice.number}</h1>
      <p>{invoice.client.name}</p>
    </header>
  );
}

The guard was written for the logo and decided for the whole header. Clients who never uploaded a logo got invoices with no number and no name at the top, and nobody noticed until one asked.

Required and optional, part by part

For each part of the screen, ask what it can’t do without and what it can. The page can’t do without the invoice. The header can’t do without the client’s name, and can do without a logo. The payments section can do without the history for a while, and can say so when it never comes.

InvoiceRoutegetInvoiceawaited: requiredloadPaymentHistorystarted: optionalInvoicePage<Payments />
The route waits for one request and starts the other. The invoice decides whether the page renders; the payment history decides only what its own section says.

Let only the data a section can’t do without decide whether it renders. Everything else gets a fallback chosen for what its absence means:

Each optional part of the invoice and what shows without it
PartWithout itWhy
The client’s logoTheir initialsThe header keeps its shape; the name beside it carries the meaning
The due date“On receipt”No date means something here, so the page says it
The purchase orderNo rowNo order number means nothing, so a label with nothing beside it would be noise
The payment history“Loading payments…”, then a sentence if it failsThe section stays where it is and says what happened

Giving each part its own fallback

Three changes: the route waits only for the invoice, the payment history becomes a value that can say it failed, and each optional field falls back on its own.

Wait for the invoice, start the history

// The invoice route, rendered on the server: wait for what the page
// can't do without, and start what it can.
import { getInvoice, getPayments } from "./api";
import { InvoicePage } from "./invoice-page";
import { loadPaymentHistory } from "./payment-history";

export async function InvoiceRoute({ id }: { id: string }) {
  // Started first and not awaited: the history streams in after the
  // page.
  const history = loadPaymentHistory(() => getPayments(id));
  const invoice = await getInvoice(id);
  return <InvoicePage invoice={invoice} history={history} />;
}

The history is started first and never awaited, so both requests run at once, and the page renders the moment the invoice is in. The history travels to the page as a promise, and only the section that needs it waits for it.

A failure that is a value

// The payment history as the page reads it: a failure becomes a
// value, so the section can say what happened while the rest of the
// invoice renders.
import type { Payment, PaymentHistory } from "./invoice";

export async function loadPaymentHistory(
  fetchPayments: () => Promise<Payment[]>,
): Promise<PaymentHistory> {
  try {
    return { ok: true, payments: await fetchPayments() };
  } catch {
    return { ok: false };
  }
}

use sends a rejected promise to the nearest error boundary. A history the service can’t provide is an outcome this page expects, not a bug, so it becomes { ok: false } where the request is made, and the section decides what to say. Errors nobody expected still reach an error boundary, which is where they belong.

The section that waits

function Payments({ history }: { history: Promise<PaymentHistory> }) {
  const result = use(history);
  if (!result.ok) {
    return (
      <p>
        The payment history couldn’t be loaded. The invoice above is
        complete; reload the page to see its payments.
      </p>
    );
  }
  if (result.payments.length === 0) return <p>No payments yet.</p>;
  return (
    <ul>
      {result.payments.map((payment) => (
        <li key={payment.id}>
          {payment.date}: {payment.amount} by {payment.method}
        </li>
      ))}
    </ul>
  );
}

Inside its Suspense boundary, the section shows “Loading payments…” until the history settles. Then it gives one of three answers: the payments, “No payments yet.”, or a sentence that says the history couldn’t be loaded and that the invoice above is complete.

Fields with fallbacks of their own

/** The client's logo, or their initials without one. */
function ClientMark({ client }: { client: Client }) {
  const [failed, setFailed] = useState(false);
  if (client.logoUrl && !failed) {
    return (
      <img
        src={client.logoUrl}
        alt=""
        width={40}
        height={40}
        onError={() => setFailed(true)}
      />
    );
  }
  const words = client.name.split(/\s+/).slice(0, 2);
  return (
    <span aria-hidden="true">
      {words.map((word) => word[0]).join("")}
    </span>
  );
}

The logo is optional twice over: the client may not have one, and the one they have may fail to load. Both lead to the initials, through the same early return.

function InvoiceDetails({ invoice }: { invoice: Invoice }) {
  return (
    <dl>
      <dt>Total</dt>
      <dd>{invoice.total}</dd>
      <dt>Due</dt>
      {/* No date means due on receipt, so say it. */}
      <dd>{invoice.dueDate ?? "On receipt"}</dd>
      {invoice.poNumber ? (
        <>
          <dt>Purchase order</dt>
          <dd>{invoice.poNumber}</dd>
        </>
      ) : null}
    </dl>
  );
}

The due date and the purchase order are both optional, and they fall back differently. A missing due date is information, so the page says “On receipt”. A missing purchase order is not, so its row leaves the list. Say it when the absence means something; leave it out when it doesn’t.

Pitfall

A logo that fails sets failed, and that state would outlive the logo. When the client uploads a new one, the address changes and the component should try again. The page renders ClientMark with the address as its key, so a new address starts a new component with fresh state, as adjusting state when a prop changes explains.

The finished page

"use client";
// The invoice page. The invoice is required and arrives with the
// page; the payment history is optional and streams in after it, and
// each optional field has its own fallback.
import { Suspense, use, useId, useState } from "react";
import type { Client, Invoice, PaymentHistory } from "./invoice";

/** The client's logo, or their initials without one. */
function ClientMark({ client }: { client: Client }) {
  const [failed, setFailed] = useState(false);
  if (client.logoUrl && !failed) {
    return (
      <img
        src={client.logoUrl}
        alt=""
        width={40}
        height={40}
        onError={() => setFailed(true)}
      />
    );
  }
  const words = client.name.split(/\s+/).slice(0, 2);
  return (
    <span aria-hidden="true">
      {words.map((word) => word[0]).join("")}
    </span>
  );
}

function InvoiceDetails({ invoice }: { invoice: Invoice }) {
  return (
    <dl>
      <dt>Total</dt>
      <dd>{invoice.total}</dd>
      <dt>Due</dt>
      {/* No date means due on receipt, so say it. */}
      <dd>{invoice.dueDate ?? "On receipt"}</dd>
      {invoice.poNumber ? (
        <>
          <dt>Purchase order</dt>
          <dd>{invoice.poNumber}</dd>
        </>
      ) : null}
    </dl>
  );
}

function Payments({ history }: { history: Promise<PaymentHistory> }) {
  const result = use(history);
  if (!result.ok) {
    return (
      <p>
        The payment history couldn’t be loaded. The invoice above is
        complete; reload the page to see its payments.
      </p>
    );
  }
  if (result.payments.length === 0) return <p>No payments yet.</p>;
  return (
    <ul>
      {result.payments.map((payment) => (
        <li key={payment.id}>
          {payment.date}: {payment.amount} by {payment.method}
        </li>
      ))}
    </ul>
  );
}

export function InvoicePage({
  invoice,
  history,
}: {
  invoice: Invoice;
  history: Promise<PaymentHistory>;
}) {
  const paymentsId = useId();
  return (
    <main>
      <header>
        {/* Keyed by the address: a new logo gets a fresh try. */}
        <ClientMark
          key={invoice.client.logoUrl}
          client={invoice.client}
        />
        <h1>Invoice {invoice.number}</h1>
        <p>{invoice.client.name}</p>
      </header>
      <InvoiceDetails invoice={invoice} />
      <section aria-labelledby={paymentsId}>
        <h2 id={paymentsId}>Payments</h2>
        <Suspense fallback={<p>Loading payments…</p>}>
          <Payments history={history} />
        </Suspense>
      </section>
    </main>
  );
}

Open INV-0042 while the payments service is slow, and the header, the total and the due date are there at once, with “Loading payments…” under the Payments heading. If the service fails, that line becomes a sentence saying so, and nothing above it moves. A client with no logo, or a logo that won’t load, gets their initials beside their name.

Where partial data gets subtle

Partial rendering is a series of small decisions. A few of them deserve more care than the rest.

Required is a question for each part

Making every field optional is not the goal. An invoice that arrives without a total is broken data, not partial data, and a total that reads “Not available” on an invoice a client is about to pay would do harm. For data a part can’t do without, return early with what happened, as in guard clauses, or let the error reach a boundary.

Starting requests before awaiting them

The order of two lines in the route sets how long the page takes. Starting the history before awaiting the invoice runs the two requests side by side. Awaiting the invoice first and starting the history afterwards would make the payments wait for the invoice and then for themselves, a waterfall the code doesn’t show.

Accessibility: every state is named

The Payments heading is outside the Suspense boundary, so it is on the page in every state. Someone moving through the page by headings finds the section and hears what it holds: loading, the payments, or why they’re missing. The logo has an empty alt and the initials are hidden from screen readers, because the client’s name is written beside them; whichever shows, the name is read once. A missing value is said in words, never as a dash, which a screen reader reads as “dash” or skips.

Testing what renders without each part

The test holds the history open, so it can check that the invoice is there before the payments are, then fails it. A second test removes the due date, fails the logo and leaves out the purchase order:

import {
  act,
  fireEvent,
  render,
  screen,
  within,
} from "@testing-library/react";
import { expect, test } from "vitest";
import { InvoicePage } from "./invoice-page";
import { loadPaymentHistory } from "./payment-history";
import type { Invoice, PaymentHistory } from "./invoice";

const INVOICE: Invoice = {
  id: "inv_42",
  number: "INV-0042",
  client: {
    name: "Brightside Studio",
    logoUrl: "/logos/brightside.png",
  },
  total: "€2,400.00",
  dueDate: "15 Dec 2026",
};

/** A payment history the test settles when it chooses. */
function held() {
  let settle = (_history: PaymentHistory) => {};
  const history = new Promise<PaymentHistory>(
    (resolve) => (settle = resolve),
  );
  return { history, settle: (h: PaymentHistory) => settle(h) };
}

test("the invoice comes first; payments follow", async () => {
  const { history, settle } = held();
  // Rendered inside an async act, so React can resume the section
  // when the history settles.
  await act(async () => {
    render(<InvoicePage invoice={INVOICE} history={history} />);
  });
  expect(
    screen.getByRole("heading", {
      level: 1,
      name: "Invoice INV-0042",
    }),
  ).toBeVisible();
  expect(screen.getByText("€2,400.00")).toBeVisible();
  const payments = screen.getByRole("region", { name: "Payments" });
  expect(
    within(payments).getByText("Loading payments…"),
  ).toBeVisible();

  await act(async () => settle({ ok: false }));
  expect(
    within(payments).getByText(/payment history couldn’t be loaded/),
  ).toBeVisible();
  expect(
    screen.getByRole("heading", {
      level: 1,
      name: "Invoice INV-0042",
    }),
  ).toBeVisible();
});

test("each missing field falls back on its own", async () => {
  const invoice: Invoice = { ...INVOICE, dueDate: undefined };
  const history = Promise.resolve<PaymentHistory>({
    ok: true,
    payments: [
      {
        id: "pay_1",
        date: "2 Dec 2026",
        amount: "€1,200.00",
        method: "card",
      },
    ],
  });
  const { container } = await act(async () =>
    render(<InvoicePage invoice={invoice} history={history} />),
  );
  // The logo fails: the initials take its place, and the name is
  // still read once.
  const logo = container.querySelector("img");
  if (!logo) throw new Error("no logo");
  fireEvent.error(logo);
  expect(container.querySelector("img")).toBeNull();
  expect(screen.getByText("BS")).toHaveAttribute(
    "aria-hidden",
    "true",
  );
  // No due date says so; no purchase order drops its row.
  expect(screen.getByText("On receipt")).toBeVisible();
  expect(screen.queryByText("Purchase order")).toBeNull();
  expect(screen.getByRole("listitem")).toHaveTextContent(
    "2 Dec 2026: €1,200.00 by card",
  );
});

test("a failed request becomes a value", async () => {
  await expect(
    loadPaymentHistory(() => Promise.reject(new Error("502"))),
  ).resolves.toEqual({
    ok: false,
  });
});

All three pass. The first renders inside an async act, which lets React resume the section when the history settles; with Promise.all, it would find no invoice heading until the payments had arrived.

Server Components and streaming

The route is a Server Component: it awaits the invoice and hands the unawaited history to the page, a Client Component that reads it with use. React’s use reference notes that a promise passed from a Server Component to a Client Component must resolve to a serialisable value, which a plain union is. With streaming, the invoice’s HTML goes out first and the section’s HTML follows when the history settles. In an app without Server Components, the same promise comes from a cache or a data library; Suspense can’t see data fetched inside an effect.

React 18 and 19

use is React 19. On React 18 the section keeps its own state from a data library or an effect, and every rule above still holds: the required data gates the page, and each optional part has its fallback. The React Compiler changes none of it, since the pattern is about what renders, not how often.

When a partial answer misleads

Some values are only true when every part is in. An outstanding balance computed from the payments that happened to load would be a wrong number shown with confidence. Hold such a value back until all of its parts have arrived, or say plainly that it is incomplete.

Forms are the other case. A form that lets people edit before all of its data has loaded can save the empty fields over values it never fetched. Keep editing closed until the data the form writes back is all there.

How Turtlekit components render what they’re given

The kit’s components treat their optional parts the same way: each renders when it is given and leaves no gap when it isn’t. Two of them carry a fallback for data that may be missing:

// Partial data with Turtlekit: Avatar shows a business's initials
// until its logo loads, and keeps them if it never does; a DataTable
// column says what a missing value means.
import { Avatar, DataTable } from "turtlekit";
import type { DataTableColumn } from "turtlekit";
import type { Client, Invoice } from "./invoice";

export function ClientLogo({ client }: { client: Client }) {
  // Beside the client's name, so the logo is decoration.
  return (
    <Avatar
      kind="business"
      name={client.name}
      src={client.logoUrl}
      decorative
    />
  );
}

const COLUMNS: DataTableColumn<Invoice>[] = [
  { key: "number", header: "Invoice" },
  { key: "total", header: "Total" },
  { key: "dueDate", header: "Due", missing: "On receipt" },
  { key: "poNumber", header: "Purchase order" },
];

export function ClientInvoices({
  invoices,
}: {
  invoices: Invoice[];
}) {
  return (
    <DataTable
      caption="Invoices"
      columns={COLUMNS}
      rows={invoices}
      getRowId={(invoice) => invoice.id}
      getRowLabel={(invoice) => invoice.number}
    />
  );
}

Avatar shows the business’s initials until its logo loads, and keeps them if it never does; a new address starts again. A DataTable column’s missing says what an empty cell means, here “On receipt”, and sorts those rows last, so a gap in the data reads as an answer rather than a blank.

Built on this pattern in Turtlekit: Avatar, Breadcrumb, Card, Empty state, Field, Image slider, Item, Media, Navigation menu, Page header, Pagination, Switch and Text field.

When one part still takes the others down

Three symptoms point to a part that still decides more than its share.

The page waits for its slowest request

Optional data is awaited with the required data, through Promise.all or one await after another. Start optional requests without awaiting them, and read each in its own Suspense boundary.

A broken-image icon shows instead of the initials

The image has no onError fallback, or the failed state carried over to a new address. Handle the error, and key the component by the address.

One service being down shows an error page

An expected failure rejects into a boundary above the page. Turn it into a value where the request is made, or put a boundary around the section that needs it.

Decide what each part needs

The first invoice page made one decision for everything on it, all or nothing, and the least important request on the page got to make it. Most screens are a set of parts with different needs. Writing those needs down part by part takes longer than one Promise.all. It is also what lets the finished page show the accountant the total and the due date while the payments service has its bad afternoon.

Error boundaries in React

When a part fails in a way nobody planned for, and should take only its section.

Skeleton placeholders in React

When a part is on its way and should hold its space meanwhile.

Empty states in React

When a part arrives with nothing in it.

Guard clauses and early returns in React

When the required data is missing and the component should return early.