Suspending children are the normal case, not an edge case. Content that reads data with use() works inside any FUNSTACK UI panel without extra props.
The library never wraps children in <Suspense> and has no fallback props. Where a fallback appears is a layout decision, so it is yours. Put a boundary wherever you would put one without the library, for example above the whole Tabs.
Selecting a tab is a transition, and panels follow the committed value. So when the newly selected panel suspends, React keeps the current panel on screen instead of showing the fallback; the tab is already selected (optimistic) and the root and tab carry data-pending. When the data arrives, the panel swaps in one commit.
Loading orders…
"use client";
import { Suspense, use, useState } from "react";
import { Tabs } from "@funstack/ui/tabs";
import { sleep } from "./fake-api.js";
// Cache the promises so `use()` sees the same one on every render.
const cache = new Map<string, Promise<string[]>>();
function loadOrders(status: string): Promise<string[]> {
let promise = cache.get(status);
if (promise === undefined) {
promise = sleep(1200).then(() => [1, 2, 3].map((n) => `${status} order #${n}0${n}`));
cache.set(status, promise);
}
return promise;
}
function Orders({ status }: { status: string }) {
const orders = use(loadOrders(status));
return (
<ul>
{orders.map((order) => (
<li key={order}>{order}</li>
))}
</ul>
);
}
const statuses = ["Open", "Shipped", "Returned"];
const tabs = ["Summary", ...statuses];
// Panels suspend while their data loads. The Suspense boundary sits above Tabs.Root, yet its
// fallback never replaces a loaded panel: selection is a transition, so the old panel stays
// (dimmed through .demo-tabs[data-pending]) and the selected tab shows a spinner.
export function TabsSuspending() {
const [generation, setGeneration] = useState(0);
return (
<>
<Suspense key={generation} fallback={<p>Loading orders…</p>}>
<Tabs.Root className="demo-tabs" defaultValue="Summary">
<Tabs.List className="demo-tabs-list" aria-label="Orders">
{tabs.map((status) => (
<Tabs.Tab key={status} value={status} className="demo-tab">
{({ pending }) => (
<>
{status}
{pending && <span className="demo-spinner" aria-hidden />}
</>
)}
</Tabs.Tab>
))}
<Tabs.Indicator className="demo-tabs-indicator" />
</Tabs.List>
{/* The first panel needs no data, so the server-rendered HTML is complete. */}
<Tabs.Panel value="Summary" className="demo-tab-panel">
<p>Pick a status. Each list takes 1.2 s to load the first time.</p>
</Tabs.Panel>
{statuses.map((status) => (
<Tabs.Panel key={status} value={status} className="demo-tab-panel">
<Orders status={status} />
</Tabs.Panel>
))}
</Tabs.Root>
</Suspense>
<button
type="button"
className="demo-button"
onClick={() => {
cache.clear();
setGeneration((g) => g + 1);
}}
>
Clear the cache and start over
</button>
</>
);
}Pressing arrow keys quickly with automatic activation dispatches one selection per key; the latest one wins, and only its panel is committed.
Accordion.Panel is always mounted (closed <details> content stays in the DOM and is searchable). Its function child receives the committed open state, which is how you mount content lazily. If that content suspends, the item stays closed with the trigger pending until the content is ready, then opens.
"use client";
import { use, useState } from "react";
import { Accordion } from "@funstack/ui/accordion";
import { sleep } from "./fake-api.js";
const cache = new Map<string, Promise<string[]>>();
function loadReplies(thread: string): Promise<string[]> {
let promise = cache.get(thread);
if (promise === undefined) {
promise = sleep(1200).then(() => [`First reply to ${thread}`, `Second reply to ${thread}`]);
cache.set(thread, promise);
}
return promise;
}
function Replies({ thread }: { thread: string }) {
const replies = use(loadReplies(thread));
return (
<ul>
{replies.map((reply) => (
<li key={reply}>{reply}</li>
))}
</ul>
);
}
const threads = ["Release planning", "Bug triage", "Design review"];
// Library mode (controlled, exclusive). The Panel's function child mounts <Replies> only once
// the item is open in committed state. Replies suspend for 1.2 s; because opening is a
// transition, the item stays closed with a spinner on its trigger until the data is ready, then
// opens with its content. No Suspense fallback is ever shown.
export function AccordionThreads() {
const [open, setOpen] = useState<string | null>(null);
return (
<>
<Accordion.Root
exclusive
className="demo-accordion"
value={open}
valueChangeAction={(next) => {
setOpen(next); // sync prefix: a Transition update
}}
>
{threads.map((thread) => (
<Accordion.Item key={thread} value={thread} className="demo-accordion-item">
<Accordion.Trigger className="demo-accordion-trigger">
{({ pending }) => (
<>
<h4>{thread}</h4>
{pending && <span className="demo-spinner" aria-hidden />}
</>
)}
</Accordion.Trigger>
<Accordion.Panel className="demo-accordion-panel">
{({ open }) => open && <Replies thread={thread} />}
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion.Root>
<output className="demo-readout">
value: <code>{JSON.stringify(open)}</code>
</output>
</>
);
}A boundary that mounts with the new content shows its fallback immediately (a React rule). That is the way to say "show a skeleton instead of waiting":
<Tabs.Panel value="orders">
<Suspense fallback={<OrdersSkeleton />}>
<Orders />
</Suspense>
</Tabs.Panel>To get a fresh fallback per item rather than a wait, key the boundary: <Suspense key={tab}>.
Trigger content is rendered from optimistic state, which updates synchronously. A children function on a Tabs.Tab, Toggle or Accordion.Trigger must not suspend (or must have its own boundary inside the trigger). Tooltip content is rendered at mount, not at open time: keep it static, or put the boundary inside Tooltip.Popup.
Pick a status. Each list takes 1.2 s to load the first time.