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.
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:
| Part | Without it | Why |
|---|---|---|
| The client’s logo | Their initials | The 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 order | No row | No order number means nothing, so a label with nothing beside it would be noise |
| The payment history | “Loading payments…”, then a sentence if it fails | The 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.