Loading States
<Pending> shows one loading state for every resource inside it, instead of one spinner per component.
import { ErrorBoundary, Pending, resource } from "@opentf/afw";
function UserList() {
const users = resource(() => fetch("/api/users").then((r) => r.json()));
return <ul>{users.data?.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}
function Stats() {
const stats = resource(() => fetch("/api/stats").then((r) => r.json()));
return <p>{stats.data?.total} users</p>;
}
export default function Dashboard() {
return (
<ErrorBoundary fallback={(error, retry) => <button onclick={retry}>Retry: {error.message}</button>}>
<Pending fallback={<Skeleton />} refreshing={<TopProgress />} delay={200}>
<UserList />
<Stats />
</Pending>
</ErrorBoundary>
);
}What it waits on
Resources created by components inside it, at any depth.
Resources read while its children are built. A page resource passed down as a prop counts:
export default function Profile() {
const user = resource(() => router.params.id, (id) => getUser(id));
return (
<Pending fallback={<ProfileSkeleton />}>
<Header name={user.data?.name} />
<Activity items={user.data?.activity} />
</Pending>
);
}Only its nearest boundary. A nested
<Pending>waits for its own resources, so an inner section can reveal later than the page around it.A page resource counts where it's read. Read only inside a section's
<Pending>, it holds that section, not an outer boundary or the page'sloading.jsx.Paused resources don't count. A resource whose source is
nullorfalsedoesn't hold the fallback.Lazy components count too. A lazy component holds the fallback until its code has loaded.
First load and refetch
| Prop | Default | What it does |
|---|---|---|
fallback | none | Shown until every resource inside has loaded once. |
refreshing | none | Shown above the content while a resource inside refetches. |
refetch | "keep" | "keep": the content stays during a refetch. "fallback": show the fallback again. |
delay | 0 | Milliseconds before fallback or refreshing appear. A faster load shows neither. |
error | none | (error, retry) => view: show a failed first load here. retry refetches what failed. |
Revealed together: the content appears once every resource has settled, not one widget at a time.
During a
delayon first load, nothing is shown; during a refetch, the content stays.Inline indicators still work:
resource.loadingistrueduring a refetch, for a spinner on one widget.Styling a refetch: the boundary has a
data-pendingattribute while its refetch state is showing:
afw-internal-pending[data-pending] > :not([slot]) {
opacity: 0.6;
transition: opacity 0.2s;
}Errors
| Situation | What happens |
|---|---|
First load fails, with an error prop | The boundary shows error(error, retry) in place of its content. Retry shows the fallback again while the failed resources reload. |
First load fails, no error prop | The error goes to the nearest <ErrorBoundary>, or the page's error.jsx. Its retry refetches the failed resources, including a page resource outside the boundary. |
| A refetch fails | The last data stays on screen, resource.error is set, and the error is reported (dev overlay, onError). |
| Nothing above to catch it | The error is reported and the content shows, so the component can read resource.error. |
{ errors: "inline" } | Only resource.error is set; nothing is reported or routed. |
Outside any
<Pending>, a failure only setsresource.error, as before.
One component per dashboard section covers both states:
<Pending fallback={<ChartSkeleton />} error={(error, retry) => <SectionError title="Revenue" onRetry={retry} />}>
<RevenueChart />
</Pending>How it displays
Children are built at once. They're connected from the start, so every fetch starts immediately; they're only hidden until the resources settle.
Hidden means not rendered. A child measuring itself in
onMountwhile the fallback shows gets zero sizes.Page CSS applies to both. The fallback and the children stay in the page DOM; a shadow root picks which one is displayed.
Layout: the boundary itself has no box (
display: contents).
Server rendering
The server renders the fallback. Resources don't fetch on the server, so the HTML shows the fallback, with the children's markup present but hidden.
Hydration adopts both. The browser shows the server fallback on first paint, adopts it without rebuilding, and reveals the content when the resources settle.
For data in the server HTML, use a route loader.