Opening a Sheet is a navigation — the user has already committed to seeing more context by clicking the trigger. When the body needs remote data, fetching before opening turns a 404 or 500 into a silent drop back to the parent view, and a spinner on the trigger leaves the click feeling unregistered. This recipe mounts the shell immediately and resolves data inside it, so every click produces forward motion and every error stays in the place the user navigated to.
No successful response is cached. Clicking happy path will fetch from scratch and show pending; closing the sheet keeps the result so reopening skips pending. The 404 and 500 scenarios throw, so reopening them always re-fetches.
Sheet.Body branches between pending, success, and error, so header, close, and footer stay mounted and layout shift is contained to one region. Across opens, TanStack Query's cache lets a re-trigger after a successful fetch skip the pending state entirely — fetch-then-open does the opposite, re-running the request on every trigger and flashing the chrome in only after each fetch resolves.500 responses are worth another attempt; a definitive 404 is not. The query hook owns that policy so the view stays free of conditional retry logic and the intent stays reviewable in one place.The route is also available as plain markdown at /blocks/sheet-async.md, so humans and LLM tools can fetch the same recipe without scraping the rendered page.
Parent selection state└── UserSheet (mounted immediately) └── useUserQuery({ userId, scenario }) └── Sheet.Content ├── Sheet.Header stable ├── Sheet.Body pending | success | error └── Sheet.Footer stable; disable actions until successThe "Why" section above is the user-perceived case for the pattern. These rules are how to wire it up so the code stays consistent with that intent.
Sheet.Body on query state. query.isPending, query.isError, and query.isSuccess belong inside the body. Header, close button, and footer stay outside the conditional.useUserQuery owns fetching, retry, stale-time, and cancellation. The components that render query results (UserSheet, UserSheetBody) consume a UseQueryResult and never reach into TanStack Query directly. The cache-observability bits in UserSheetDemo (the useQueryClient subscription and removeQueries reset) are demo-only — production sheets shouldn't touch the cache.useQuery. Return false from retry for terminal errors like 404. Return a bounded failureCount check for transient ones like 500.Copy the UI recipe into a client component that already has a QueryClientProvider mounted near the app root. The demo-only mock API is shown in the appendix below; in production, replace that import with your real API client and error type.
1import { Button } from "@ngrok/mantle/button";2import { DescriptionList } from "@ngrok/mantle/description-list";3import { Empty } from "@ngrok/mantle/empty";4import { MediaObject } from "@ngrok/mantle/media-object";5import { Separator } from "@ngrok/mantle/separator";6import { Sheet } from "@ngrok/mantle/sheet";7import { Skeleton } from "@ngrok/mantle/skeleton";8import { MagnifyingGlassIcon } from "@phosphor-icons/react/MagnifyingGlass";9import { SmileyMeltingIcon } from "@phosphor-icons/react/SmileyMelting";10import {11 useQuery,12 useQueryClient,13 type QueryClient,14 type UseQueryResult,15} from "@tanstack/react-query";16import { type ReactNode, useEffect, useState } from "react";17import {18 fetchUser,19 RequestCancelledError,20 UserRequestError,21 type User,22 type UserSheetScenario,23} from "./mock-user-api";24 25const SERVER_ERROR_RETRY_LIMIT = 2;26const USER_QUERY_RETRY_DELAY_MS = 500;27const USER_QUERY_KEY_ROOT = ["sheet-async-user"];28 29const scenarioLabels: Record<UserSheetScenario, string> = {30 success: "happy path",31 "not-found": "404",32 "server-error": "500",33};34 35/** Props for the controlled async sheet. */36type UserSheetProps = {37 /** User identifier requested by the sheet. */38 userId: string;39 /** Demo scenario selected by the user. */40 scenario: UserSheetScenario;41 /** Called when the user dismisses the sheet. */42 onClose: () => void;43};44 45/** Renders immediately, then swaps the sheet body from pending to success or error. */46export function UserSheet({ onClose, scenario, userId }: UserSheetProps) {47 const query = useUserQuery({ scenario, userId });48 49 return (50 <Sheet.Root51 open52 onOpenChange={(nextOpen) => {53 if (!nextOpen) {54 onClose();55 }56 }}57 >58 <Sheet.Content preferredWidth="sm:max-w-[34rem]">59 <Sheet.Header>60 <Sheet.TitleGroup>61 <Sheet.Title>User details</Sheet.Title>62 <Sheet.Actions>63 <Sheet.CloseIconButton />64 </Sheet.Actions>65 </Sheet.TitleGroup>66 <Sheet.Description>67 Loading <span className="font-mono">{userId}</span> through the{" "}68 {scenarioLabels[scenario]} case.69 </Sheet.Description>70 </Sheet.Header>71 <Sheet.Body>72 <UserSheetBody query={query} />73 </Sheet.Body>74 <Sheet.Footer>75 <Sheet.Close asChild>76 <Button type="button" appearance="outlined" priority="neutral">77 Close78 </Button>79 </Sheet.Close>80 <Button type="button" appearance="filled" priority="neutral" disabled={!query.isSuccess}>81 Save changes82 </Button>83 </Sheet.Footer>84 </Sheet.Content>85 </Sheet.Root>86 );87}88 89/** Props for selecting which body state to render. */90type UserSheetBodyProps = {91 /** TanStack Query result that drives the body state. */92 query: UseQueryResult<User, Error>;93};94 95/** Keeps the sheet chrome stable while only the body content changes. */96function UserSheetBody({ query }: UserSheetBodyProps) {97 if (query.isPending) {98 return <UserDetailsLoading failureCount={query.failureCount} />;99 }100 101 if (query.isError) {102 return (103 <UserDetailsError104 error={query.error}105 isRetrying={query.isFetching}106 onRetry={() => {107 void query.refetch();108 }}109 />110 );111 }112 113 if (query.isSuccess) {114 return <UserDetails user={query.data} />;115 }116 117 return null;118}119 120/** Props for the loading state. */121type UserDetailsLoadingProps = {122 /** Failed attempts observed while TanStack Query is still retrying. */123 failureCount: number;124};125 126/** Mirrors the successful layout so the sheet body does not jump when data arrives. */127function UserDetailsLoading({ failureCount }: UserDetailsLoadingProps) {128 return (129 <div className="space-y-4" aria-busy="true" aria-label="Loading user details">130 <MediaObject.Root className="items-center">131 <MediaObject.Media>132 <Skeleton className="size-12 rounded-full" />133 </MediaObject.Media>134 <MediaObject.Content className="space-y-2">135 <Skeleton className="h-4 w-40" />136 <Skeleton className="h-3 w-56" />137 </MediaObject.Content>138 </MediaObject.Root>139 <Separator />140 <div className="space-y-2">141 <Skeleton className="h-3 w-full" />142 <Skeleton className="h-3 w-5/6" />143 <Skeleton className="h-3 w-3/4" />144 </div>145 {failureCount > 0 && (146 <p className="text-sm text-muted" role="status">147 Retrying after {failureCount} failed {failureCount === 1 ? "attempt" : "attempts"}.148 </p>149 )}150 </div>151 );152}153 154/** Props for the inline error state. */155type UserDetailsErrorProps = {156 /** Error thrown by the query function. */157 error: Error;158 /** Whether a manual retry is currently in flight. */159 isRetrying: boolean;160 /** Called when the user requests another attempt. */161 onRetry: () => void;162};163 164/** Renders a recoverable inline error instead of closing the sheet. */165function UserDetailsError({ error, isRetrying, onRetry }: UserDetailsErrorProps) {166 const copy = getUserErrorCopy(error);167 168 return (169 <Empty.Root className="py-8">170 <Empty.Icon svg={copy.icon} />171 <Empty.Title className="text-lg">{copy.title}</Empty.Title>172 <Empty.Description>173 <p>{copy.description}</p>174 {copy.requestId && (175 <p>176 Request ID <span className="font-mono text-strong">{copy.requestId}</span>177 </p>178 )}179 </Empty.Description>180 {copy.canRetry && (181 <Empty.Actions>182 <Button183 type="button"184 appearance="filled"185 priority="danger"186 isLoading={isRetrying}187 onClick={onRetry}188 >189 Retry request190 </Button>191 </Empty.Actions>192 )}193 </Empty.Root>194 );195}196 197/** Copy values shown by the inline error state. */198type UserErrorCopy = {199 /** Whether the Empty state should render a retry action. */200 canRetry: boolean;201 /** Icon shown by the Empty state. */202 icon: ReactNode;203 /** Short Empty state title. */204 title: string;205 /** Human-readable remediation context. */206 description: string;207 /** Optional request id shown for support workflows. */208 requestId?: string;209};210 211/** Converts typed request errors into user-facing copy. */212function getUserErrorCopy(error: Error): UserErrorCopy {213 if (error instanceof UserRequestError && error.status === 404) {214 return {215 canRetry: false,216 icon: <MagnifyingGlassIcon />,217 title: "User not found",218 description: "The request completed, but no user exists for that id.",219 requestId: error.requestId,220 };221 }222 223 if (error instanceof UserRequestError && error.status === 500) {224 return {225 canRetry: true,226 icon: <SmileyMeltingIcon />,227 title: "User service unavailable",228 description:229 "The service failed after retrying. Keep the sheet open so the user can try again.",230 requestId: error.requestId,231 };232 }233 234 return {235 canRetry: true,236 icon: <SmileyMeltingIcon />,237 title: "Could not load user",238 description: error.message || "An unknown error occurred.",239 };240}241 242/** Props for the successful user details state. */243type UserDetailsProps = {244 /** Loaded user data returned by the query. */245 user: User;246};247 248/** Renders the successful data state. */249function UserDetails({ user }: UserDetailsProps) {250 return (251 <div className="space-y-4">252 <MediaObject.Root className="items-center">253 <MediaObject.Media>254 <div className="flex size-12 items-center justify-center rounded-full bg-accent-500 font-mono text-on-filled">255 {user.name[0]}256 </div>257 </MediaObject.Media>258 <MediaObject.Content>259 <p className="font-medium text-strong">{user.name}</p>260 <p className="text-sm text-muted">{user.email}</p>261 </MediaObject.Content>262 </MediaObject.Root>263 <Separator />264 <DescriptionList.Root>265 <DescriptionList.Item>266 <DescriptionList.Label>ID</DescriptionList.Label>267 <DescriptionList.Value>{user.id}</DescriptionList.Value>268 </DescriptionList.Item>269 <DescriptionList.Item>270 <DescriptionList.Label>Role</DescriptionList.Label>271 <DescriptionList.Value>{user.role}</DescriptionList.Value>272 </DescriptionList.Item>273 <DescriptionList.Item>274 <DescriptionList.Label>Workspace</DescriptionList.Label>275 <DescriptionList.Value>{user.workspace}</DescriptionList.Value>276 </DescriptionList.Item>277 <DescriptionList.Item>278 <DescriptionList.Label>Plan</DescriptionList.Label>279 <DescriptionList.Value>{user.plan}</DescriptionList.Value>280 </DescriptionList.Item>281 <DescriptionList.Item>282 <DescriptionList.Label>Joined</DescriptionList.Label>283 <DescriptionList.Value>{user.joinedAt}</DescriptionList.Value>284 </DescriptionList.Item>285 <DescriptionList.Item>286 <DescriptionList.Label>Last active</DescriptionList.Label>287 <DescriptionList.Value>{user.lastActiveAt}</DescriptionList.Value>288 </DescriptionList.Item>289 </DescriptionList.Root>290 </div>291 );292}293 294/** Selected sheet state owned by the parent list/page. */295type SelectedUserSheet = {296 /** User identifier requested by the sheet. */297 userId: string;298 /** Demo scenario selected by the user. */299 scenario: UserSheetScenario;300};301 302/** Query key for the success scenario, used to observe and clear just the happy-path cache entry. */303const HAPPY_PATH_QUERY_KEY = [...USER_QUERY_KEY_ROOT, userIdForScenario("success"), "success"];304 305/** Tracks whether the happy-path query has a successful response in the cache. The 404 and 500 scenarios throw, so they never reach a successful cache state. */306function useIsHappyPathCached(): boolean {307 const queryClient = useQueryClient();308 const [isCached, setIsCached] = useState(() => readIsHappyPathCached(queryClient));309 310 useEffect(() => {311 const cache = queryClient.getQueryCache();312 const unsubscribe = cache.subscribe((event) => {313 // Ignore cache events that don't touch the happy-path query so other consumers of the314 // QueryClient don't trigger a recompute on every cache mutation.315 if (!isHappyPathCacheEvent(event)) {316 return;317 }318 const next = readIsHappyPathCached(queryClient);319 setIsCached((prev) => (prev === next ? prev : next));320 });321 return unsubscribe;322 }, [queryClient]);323 324 return isCached;325}326 327/** Returns true when the happy-path query has a successful response in the cache. */328function readIsHappyPathCached(queryClient: QueryClient): boolean {329 const query = queryClient.getQueryCache().find({ queryKey: HAPPY_PATH_QUERY_KEY, exact: true });330 return query?.state.status === "success";331}332 333/** True when a query-cache event refers to the happy-path query key. */334function isHappyPathCacheEvent(event: { query: { queryKey: readonly unknown[] } }): boolean {335 const queryKey = event.query.queryKey;336 if (queryKey.length !== HAPPY_PATH_QUERY_KEY.length) {337 return false;338 }339 return queryKey.every((part, index) => part === HAPPY_PATH_QUERY_KEY[index]);340}341 342/** Builds the cache-state caption shown beneath the demo controls. */343function describeCacheState(isCached: boolean): string {344 if (isCached) {345 return `Happy path is cached. Reopening it now skips the pending state and renders the result immediately. Click "Clear cache" to reset.`;346 }347 return "No successful response is cached. Clicking happy path will fetch from scratch and show pending; closing the sheet keeps the result so reopening skips pending. The 404 and 500 scenarios throw, so reopening them always re-fetches.";348}349 350/** Opens the three async sheet states from parent-owned selection state. */351export function UserSheetDemo() {352 const [selection, setSelection] = useState<SelectedUserSheet | null>(null);353 const queryClient = useQueryClient();354 const isHappyPathCached = useIsHappyPathCached();355 356 /** Opens a fresh sheet for the selected demo case. */357 const openSheet = (scenario: UserSheetScenario) => {358 setSelection({ scenario, userId: userIdForScenario(scenario) });359 };360 361 /** Dismisses the sheet without clearing the cache so reopening can demonstrate skip-pending. */362 const closeSheet = () => {363 setSelection(null);364 };365 366 /** Removes every cached sheet-async response so the next click shows the pending state again. */367 const clearCache = () => {368 queryClient.removeQueries({ queryKey: USER_QUERY_KEY_ROOT });369 };370 371 return (372 <div className="flex flex-col gap-3">373 <div className="flex flex-wrap items-center gap-2">374 <Button375 type="button"376 appearance="filled"377 priority="neutral"378 onClick={() => openSheet("success")}379 >380 Happy path381 </Button>382 <Button383 type="button"384 appearance="outlined"385 priority="neutral"386 onClick={() => openSheet("not-found")}387 >388 404 error389 </Button>390 <Button391 type="button"392 appearance="filled"393 priority="danger"394 onClick={() => openSheet("server-error")}395 >396 500 error397 </Button>398 </div>399 <div className="flex flex-wrap items-center gap-2">400 <Button401 type="button"402 appearance="ghost"403 priority="neutral"404 onClick={clearCache}405 disabled={!isHappyPathCached}406 >407 Clear cache408 </Button>409 </div>410 <p className="text-sm text-muted" aria-live="polite">411 {describeCacheState(isHappyPathCached)}412 </p>413 {selection && (414 <UserSheet userId={selection.userId} scenario={selection.scenario} onClose={closeSheet} />415 )}416 </div>417 );418}419 420/** Returns the user id that makes the selected scenario readable in the UI. */421function userIdForScenario(scenario: UserSheetScenario): string {422 if (scenario === "not-found") {423 return "user_missing";424 }425 426 return "user_01J8Q7Z5K2";427}428 429/** Options passed to the demo query hook. */430type UseUserQueryOptions = {431 /** User identifier requested by the sheet. */432 userId: string;433 /** Demo scenario selected by the user. */434 scenario: UserSheetScenario;435};436 437/** Fetches user details for the sheet with TanStack Query and an explicit retry policy. */438function useUserQuery({ scenario, userId }: UseUserQueryOptions): UseQueryResult<User, Error> {439 return useQuery<User, Error>({440 queryKey: [...USER_QUERY_KEY_ROOT, userId, scenario],441 queryFn: ({ signal }) => fetchUser({ userId, scenario, signal }),442 retry: (failureCount, error) => shouldRetryUserQuery({ error, failureCount }),443 retryDelay: USER_QUERY_RETRY_DELAY_MS,444 staleTime: 30_000,445 gcTime: 5 * 60_000,446 });447}448 449/** Options passed to the query retry policy. */450type ShouldRetryUserQueryOptions = {451 /** Number of failed attempts TanStack Query has observed. */452 failureCount: number;453 /** Error thrown by the query function. */454 error: Error;455};456 457/** Retries transient server failures while skipping terminal and cancelled requests. */458function shouldRetryUserQuery({ error, failureCount }: ShouldRetryUserQueryOptions): boolean {459 if (error instanceof RequestCancelledError) {460 return false;461 }462 463 if (error instanceof UserRequestError && error.status === 500) {464 return failureCount < SERVER_ERROR_RETRY_LIMIT;465 }466 467 return false;468}The code above imports a mock API module so the sheet can demonstrate happy, 404, and 500 states without a backend. In production, replace this file with your real API client and preserve the same query behavior: accept signal, throw typed errors, and let the UI decide which errors are retryable.
1const SIMULATED_USER_API_LATENCY_MS = 1_500;2 3/** Scenario names used by the docs demo controls. */4export type UserSheetScenario = "success" | "not-found" | "server-error";5 6/** The typed user record returned by the demo API. */7export type User = {8 /** Stable user identifier from the backend. */9 id: string;10 /** Display name shown in the sheet header content. */11 name: string;12 /** Primary email address for the account. */13 email: string;14 /** Role within the current workspace. */15 role: string;16 /** Workspace name used to make the example feel like app data. */17 workspace: string;18 /** Billing plan attached to the workspace. */19 plan: string;20 /** ISO date string for when the user joined. */21 joinedAt: string;22 /** Human-readable recent activity summary. */23 lastActiveAt: string;24};25 26/** The HTTP status codes intentionally simulated by the demo. */27type UserRequestErrorStatus = 404 | 500;28 29/** Options for constructing a typed demo request error. */30type UserRequestErrorOptions = {31 /** HTTP status returned by the simulated endpoint. */32 status: UserRequestErrorStatus;33 /** User-facing or operator-facing error message. */34 message: string;35 /** Request identifier included in the inline error UI. */36 requestId: string;37};38 39/** Error shape used when the simulated endpoint returns a non-2xx response. */40export class UserRequestError extends Error {41 /** HTTP status returned by the simulated endpoint. */42 readonly status: UserRequestErrorStatus;43 /** Request identifier included in the inline error UI. */44 readonly requestId: string;45 46 /** Creates a typed request error that preserves HTTP metadata. */47 constructor({ message, requestId, status }: UserRequestErrorOptions) {48 super(message);49 this.name = "UserRequestError";50 this.requestId = requestId;51 this.status = status;52 }53}54 55/** Error used when TanStack Query aborts a request after the sheet unmounts. */56export class RequestCancelledError extends Error {57 /** Creates a cancellation error that the retry policy can ignore. */58 constructor() {59 super("The user details request was cancelled.");60 this.name = "RequestCancelledError";61 }62}63 64/** Options for delaying the simulated API response. */65type WaitForDemoApiOptions = {66 /** Delay duration in milliseconds. */67 durationMs: number;68 /** Abort signal passed by TanStack Query. */69 signal?: AbortSignal;70};71 72/** Waits before resolving so the sheet can show the pending state. */73function waitForDemoApi({ durationMs, signal }: WaitForDemoApiOptions): Promise<void> {74 return new Promise((resolve, reject) => {75 if (signal?.aborted) {76 reject(new RequestCancelledError());77 return;78 }79 80 const timeoutId = setTimeout(() => {81 signal?.removeEventListener("abort", handleAbort);82 resolve();83 }, durationMs);84 85 /** Cancels the simulated API delay when the query unmounts. */86 function handleAbort() {87 clearTimeout(timeoutId);88 signal?.removeEventListener("abort", handleAbort);89 reject(new RequestCancelledError());90 }91 92 signal?.addEventListener("abort", handleAbort, { once: true });93 });94}95 96/** Options accepted by the simulated user fetcher. */97type FetchUserOptions = {98 /** User identifier requested by the sheet. */99 userId: string;100 /** Demo scenario selected by the user. */101 scenario: UserSheetScenario;102 /** Abort signal passed by TanStack Query. */103 signal?: AbortSignal;104};105 106/** Simulates a production API client with latency, typed data, and HTTP errors. */107export async function fetchUser({ scenario, signal, userId }: FetchUserOptions): Promise<User> {108 await waitForDemoApi({ durationMs: SIMULATED_USER_API_LATENCY_MS, signal });109 110 if (scenario === "not-found") {111 throw new UserRequestError({112 status: 404,113 message: `No user exists for ${userId}.`,114 requestId: createDemoRequestId({ status: 404, userId }),115 });116 }117 118 if (scenario === "server-error") {119 throw new UserRequestError({120 status: 500,121 message: "The user service failed before returning profile data.",122 requestId: createDemoRequestId({ status: 500, userId }),123 });124 }125 126 return {127 id: userId,128 name: "Ada Lovelace",129 email: "ada.lovelace@example.com",130 role: "Founding Engineer",131 workspace: "Analytical Engines",132 plan: "Enterprise",133 joinedAt: "1843-07-08",134 lastActiveAt: "2 minutes ago",135 };136}137 138/** Options for creating a deterministic request id in the demo. */139type CreateDemoRequestIdOptions = {140 /** HTTP status returned by the simulated endpoint. */141 status: UserRequestErrorStatus;142 /** User identifier requested by the sheet. */143 userId: string;144};145 146/** Creates a stable request id so the error UI looks like real production output. */147function createDemoRequestId({ status, userId }: CreateDemoRequestIdOptions): string {148 return `req_${status}_${userId.replace(/[^a-z0-9]/gi, "").slice(-8)}`;149}fetchUser or your app's equivalent data client.signal argument from TanStack Query's queryFn when your API client supports cancellation.retry by status code. This demo does not retry 404 because the server already answered definitively. Retry not-found responses only when eventual consistency is expected.staleTime and gcTime govern cache lifetime; the sheet does not need to manually evict on close. The demo's "Clear cache" button only exists to make the cache state observable in docs — production sheets typically don't surface a manual reset.useSuspenseQuery. Suspense works, but error recovery requires an additional reset boundary and is harder to copy into route-controlled sheets.