---
title: Sheet + Async Data (React Query)
description: Render a Sheet immediately, then swap its body between pending, success, 404, and 500 states with TanStack Query.
---

# Sheet + Async Data (React Query)

Opening a [`Sheet`](/components/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.

## Why open the sheet first?

- **The open should feel instant, especially on slow networks.** The user already committed to the navigation by clicking the trigger. Waiting for the request to resolve before opening means dead air at the parent view, and the wait scales with request latency — long-running endpoints or slow connections turn into seconds where users can't tell whether the click registered. Mounting the shell immediately decouples click feedback from request time.
- **No flashing across the open → load → close → reopen cycle.** Within a single open, only `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.
- **Errors stay where the user intended to be.** An inline error inside the sheet preserves the navigation and keeps recovery actions in reach. Closing on error forces a re-trigger and re-orientation from the parent view.
- **Retry is a product decision, not a default.** Transient `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.

## What this covers

| Case       | Demo behavior                                                                     |
| ---------- | --------------------------------------------------------------------------------- |
| Happy path | Waits 1.5 seconds, then renders a loaded user profile.                            |
| 404 error  | Waits 1.5 seconds once, then renders a not-found empty state.                     |
| 500 error  | Waits 1.5 seconds per request, retries twice, then renders a service empty state. |

The route is also available as plain markdown at [`/blocks/sheet-async.md`](/blocks/sheet-async.md), so humans and LLM tools can fetch the same recipe without scraping the rendered page.

## Anatomy

```text showLineNumbers=false
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 success
```

## Implementation

The "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.

1. **Mount the sheet on the click event.** Set selection state synchronously in the handler — never gate it on a fetch result.
2. **Branch only `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.
3. **Factor the query into a hook.** `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.
4. **Encode the retry policy in `useQuery`.** Return `false` from `retry` for terminal errors like `404`. Return a bounded `failureCount` check for transient ones like `500`.
5. **Match the retry UI to the retry policy.** Show a retry button on recoverable errors. Omit it on terminal ones — retrying a definitive not-found is just latency without recovery.

## Complete Recipe

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.

```tsx
import { Button } from "@ngrok/mantle/button";
import { DescriptionList } from "@ngrok/mantle/description-list";
import { Empty } from "@ngrok/mantle/empty";
import { MediaObject } from "@ngrok/mantle/media-object";
import { Separator } from "@ngrok/mantle/separator";
import { Sheet } from "@ngrok/mantle/sheet";
import { Skeleton } from "@ngrok/mantle/skeleton";
import { MagnifyingGlassIcon } from "@phosphor-icons/react/MagnifyingGlass";
import { SmileyMeltingIcon } from "@phosphor-icons/react/SmileyMelting";
import {
	useQuery,
	useQueryClient,
	type QueryClient,
	type UseQueryResult,
} from "@tanstack/react-query";
import { type ReactNode, useEffect, useState } from "react";
import {
	fetchUser,
	RequestCancelledError,
	UserRequestError,
	type User,
	type UserSheetScenario,
} from "./mock-user-api";

const SERVER_ERROR_RETRY_LIMIT = 2;
const USER_QUERY_RETRY_DELAY_MS = 500;
const USER_QUERY_KEY_ROOT = ["sheet-async-user"];

const scenarioLabels: Record<UserSheetScenario, string> = {
	success: "happy path",
	"not-found": "404",
	"server-error": "500",
};

/** Props for the controlled async sheet. */
type UserSheetProps = {
	/** User identifier requested by the sheet. */
	userId: string;
	/** Demo scenario selected by the user. */
	scenario: UserSheetScenario;
	/** Called when the user dismisses the sheet. */
	onClose: () => void;
};

/** Renders immediately, then swaps the sheet body from pending to success or error. */
export function UserSheet({ onClose, scenario, userId }: UserSheetProps) {
	const query = useUserQuery({ scenario, userId });

	return (
		<Sheet.Root
			open
			onOpenChange={(nextOpen) => {
				if (!nextOpen) {
					onClose();
				}
			}}
		>
			<Sheet.Content preferredWidth="sm:max-w-[34rem]">
				<Sheet.Header>
					<Sheet.TitleGroup>
						<Sheet.Title>User details</Sheet.Title>
						<Sheet.Actions>
							<Sheet.CloseIconButton />
						</Sheet.Actions>
					</Sheet.TitleGroup>
					<Sheet.Description>
						Loading <span className="font-mono">{userId}</span> through the{" "}
						{scenarioLabels[scenario]} case.
					</Sheet.Description>
				</Sheet.Header>
				<Sheet.Body>
					<UserSheetBody query={query} />
				</Sheet.Body>
				<Sheet.Footer>
					<Sheet.Close asChild>
						<Button type="button" appearance="outlined" priority="neutral">
							Close
						</Button>
					</Sheet.Close>
					<Button type="button" appearance="filled" priority="neutral" disabled={!query.isSuccess}>
						Save changes
					</Button>
				</Sheet.Footer>
			</Sheet.Content>
		</Sheet.Root>
	);
}

/** Props for selecting which body state to render. */
type UserSheetBodyProps = {
	/** TanStack Query result that drives the body state. */
	query: UseQueryResult<User, Error>;
};

/** Keeps the sheet chrome stable while only the body content changes. */
function UserSheetBody({ query }: UserSheetBodyProps) {
	if (query.isPending) {
		return <UserDetailsLoading failureCount={query.failureCount} />;
	}

	if (query.isError) {
		return (
			<UserDetailsError
				error={query.error}
				isRetrying={query.isFetching}
				onRetry={() => {
					void query.refetch();
				}}
			/>
		);
	}

	if (query.isSuccess) {
		return <UserDetails user={query.data} />;
	}

	return null;
}

/** Props for the loading state. */
type UserDetailsLoadingProps = {
	/** Failed attempts observed while TanStack Query is still retrying. */
	failureCount: number;
};

/** Mirrors the successful layout so the sheet body does not jump when data arrives. */
function UserDetailsLoading({ failureCount }: UserDetailsLoadingProps) {
	return (
		<div className="space-y-4" aria-busy="true" aria-label="Loading user details">
			<MediaObject.Root className="items-center">
				<MediaObject.Media>
					<Skeleton className="size-12 rounded-full" />
				</MediaObject.Media>
				<MediaObject.Content className="space-y-2">
					<Skeleton className="h-4 w-40" />
					<Skeleton className="h-3 w-56" />
				</MediaObject.Content>
			</MediaObject.Root>
			<Separator />
			<div className="space-y-2">
				<Skeleton className="h-3 w-full" />
				<Skeleton className="h-3 w-5/6" />
				<Skeleton className="h-3 w-3/4" />
			</div>
			{failureCount > 0 && (
				<p className="text-sm text-muted" role="status">
					Retrying after {failureCount} failed {failureCount === 1 ? "attempt" : "attempts"}.
				</p>
			)}
		</div>
	);
}

/** Props for the inline error state. */
type UserDetailsErrorProps = {
	/** Error thrown by the query function. */
	error: Error;
	/** Whether a manual retry is currently in flight. */
	isRetrying: boolean;
	/** Called when the user requests another attempt. */
	onRetry: () => void;
};

/** Renders a recoverable inline error instead of closing the sheet. */
function UserDetailsError({ error, isRetrying, onRetry }: UserDetailsErrorProps) {
	const copy = getUserErrorCopy(error);

	return (
		<Empty.Root className="py-8">
			<Empty.Icon svg={copy.icon} />
			<Empty.Title className="text-lg">{copy.title}</Empty.Title>
			<Empty.Description>
				<p>{copy.description}</p>
				{copy.requestId && (
					<p>
						Request ID <span className="font-mono text-strong">{copy.requestId}</span>
					</p>
				)}
			</Empty.Description>
			{copy.canRetry && (
				<Empty.Actions>
					<Button
						type="button"
						appearance="filled"
						priority="danger"
						isLoading={isRetrying}
						onClick={onRetry}
					>
						Retry request
					</Button>
				</Empty.Actions>
			)}
		</Empty.Root>
	);
}

/** Copy values shown by the inline error state. */
type UserErrorCopy = {
	/** Whether the Empty state should render a retry action. */
	canRetry: boolean;
	/** Icon shown by the Empty state. */
	icon: ReactNode;
	/** Short Empty state title. */
	title: string;
	/** Human-readable remediation context. */
	description: string;
	/** Optional request id shown for support workflows. */
	requestId?: string;
};

/** Converts typed request errors into user-facing copy. */
function getUserErrorCopy(error: Error): UserErrorCopy {
	if (error instanceof UserRequestError && error.status === 404) {
		return {
			canRetry: false,
			icon: <MagnifyingGlassIcon />,
			title: "User not found",
			description: "The request completed, but no user exists for that id.",
			requestId: error.requestId,
		};
	}

	if (error instanceof UserRequestError && error.status === 500) {
		return {
			canRetry: true,
			icon: <SmileyMeltingIcon />,
			title: "User service unavailable",
			description:
				"The service failed after retrying. Keep the sheet open so the user can try again.",
			requestId: error.requestId,
		};
	}

	return {
		canRetry: true,
		icon: <SmileyMeltingIcon />,
		title: "Could not load user",
		description: error.message || "An unknown error occurred.",
	};
}

/** Props for the successful user details state. */
type UserDetailsProps = {
	/** Loaded user data returned by the query. */
	user: User;
};

/** Renders the successful data state. */
function UserDetails({ user }: UserDetailsProps) {
	return (
		<div className="space-y-4">
			<MediaObject.Root className="items-center">
				<MediaObject.Media>
					<div className="flex size-12 items-center justify-center rounded-full bg-accent-500 font-mono text-on-filled">
						{user.name[0]}
					</div>
				</MediaObject.Media>
				<MediaObject.Content>
					<p className="font-medium text-strong">{user.name}</p>
					<p className="text-sm text-muted">{user.email}</p>
				</MediaObject.Content>
			</MediaObject.Root>
			<Separator />
			<DescriptionList.Root>
				<DescriptionList.Item>
					<DescriptionList.Label>ID</DescriptionList.Label>
					<DescriptionList.Value>{user.id}</DescriptionList.Value>
				</DescriptionList.Item>
				<DescriptionList.Item>
					<DescriptionList.Label>Role</DescriptionList.Label>
					<DescriptionList.Value>{user.role}</DescriptionList.Value>
				</DescriptionList.Item>
				<DescriptionList.Item>
					<DescriptionList.Label>Workspace</DescriptionList.Label>
					<DescriptionList.Value>{user.workspace}</DescriptionList.Value>
				</DescriptionList.Item>
				<DescriptionList.Item>
					<DescriptionList.Label>Plan</DescriptionList.Label>
					<DescriptionList.Value>{user.plan}</DescriptionList.Value>
				</DescriptionList.Item>
				<DescriptionList.Item>
					<DescriptionList.Label>Joined</DescriptionList.Label>
					<DescriptionList.Value>{user.joinedAt}</DescriptionList.Value>
				</DescriptionList.Item>
				<DescriptionList.Item>
					<DescriptionList.Label>Last active</DescriptionList.Label>
					<DescriptionList.Value>{user.lastActiveAt}</DescriptionList.Value>
				</DescriptionList.Item>
			</DescriptionList.Root>
		</div>
	);
}

/** Selected sheet state owned by the parent list/page. */
type SelectedUserSheet = {
	/** User identifier requested by the sheet. */
	userId: string;
	/** Demo scenario selected by the user. */
	scenario: UserSheetScenario;
};

/** Query key for the success scenario, used to observe and clear just the happy-path cache entry. */
const HAPPY_PATH_QUERY_KEY = [...USER_QUERY_KEY_ROOT, userIdForScenario("success"), "success"];

/** 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. */
function useIsHappyPathCached(): boolean {
	const queryClient = useQueryClient();
	const [isCached, setIsCached] = useState(() => readIsHappyPathCached(queryClient));

	useEffect(() => {
		const cache = queryClient.getQueryCache();
		const unsubscribe = cache.subscribe((event) => {
			// Ignore cache events that don't touch the happy-path query so other consumers of the
			// QueryClient don't trigger a recompute on every cache mutation.
			if (!isHappyPathCacheEvent(event)) {
				return;
			}
			const next = readIsHappyPathCached(queryClient);
			setIsCached((prev) => (prev === next ? prev : next));
		});
		return unsubscribe;
	}, [queryClient]);

	return isCached;
}

/** Returns true when the happy-path query has a successful response in the cache. */
function readIsHappyPathCached(queryClient: QueryClient): boolean {
	const query = queryClient.getQueryCache().find({ queryKey: HAPPY_PATH_QUERY_KEY, exact: true });
	return query?.state.status === "success";
}

/** True when a query-cache event refers to the happy-path query key. */
function isHappyPathCacheEvent(event: { query: { queryKey: readonly unknown[] } }): boolean {
	const queryKey = event.query.queryKey;
	if (queryKey.length !== HAPPY_PATH_QUERY_KEY.length) {
		return false;
	}
	return queryKey.every((part, index) => part === HAPPY_PATH_QUERY_KEY[index]);
}

/** Builds the cache-state caption shown beneath the demo controls. */
function describeCacheState(isCached: boolean): string {
	if (isCached) {
		return `Happy path is cached. Reopening it now skips the pending state and renders the result immediately. Click "Clear cache" to reset.`;
	}
	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.";
}

/** Opens the three async sheet states from parent-owned selection state. */
export function UserSheetDemo() {
	const [selection, setSelection] = useState<SelectedUserSheet | null>(null);
	const queryClient = useQueryClient();
	const isHappyPathCached = useIsHappyPathCached();

	/** Opens a fresh sheet for the selected demo case. */
	const openSheet = (scenario: UserSheetScenario) => {
		setSelection({ scenario, userId: userIdForScenario(scenario) });
	};

	/** Dismisses the sheet without clearing the cache so reopening can demonstrate skip-pending. */
	const closeSheet = () => {
		setSelection(null);
	};

	/** Removes every cached sheet-async response so the next click shows the pending state again. */
	const clearCache = () => {
		queryClient.removeQueries({ queryKey: USER_QUERY_KEY_ROOT });
	};

	return (
		<div className="flex flex-col gap-3">
			<div className="flex flex-wrap items-center gap-2">
				<Button
					type="button"
					appearance="filled"
					priority="neutral"
					onClick={() => openSheet("success")}
				>
					Happy path
				</Button>
				<Button
					type="button"
					appearance="outlined"
					priority="neutral"
					onClick={() => openSheet("not-found")}
				>
					404 error
				</Button>
				<Button
					type="button"
					appearance="filled"
					priority="danger"
					onClick={() => openSheet("server-error")}
				>
					500 error
				</Button>
			</div>
			<div className="flex flex-wrap items-center gap-2">
				<Button
					type="button"
					appearance="ghost"
					priority="neutral"
					onClick={clearCache}
					disabled={!isHappyPathCached}
				>
					Clear cache
				</Button>
			</div>
			<p className="text-sm text-muted" aria-live="polite">
				{describeCacheState(isHappyPathCached)}
			</p>
			{selection && (
				<UserSheet userId={selection.userId} scenario={selection.scenario} onClose={closeSheet} />
			)}
		</div>
	);
}

/** Returns the user id that makes the selected scenario readable in the UI. */
function userIdForScenario(scenario: UserSheetScenario): string {
	if (scenario === "not-found") {
		return "user_missing";
	}

	return "user_01J8Q7Z5K2";
}

/** Options passed to the demo query hook. */
type UseUserQueryOptions = {
	/** User identifier requested by the sheet. */
	userId: string;
	/** Demo scenario selected by the user. */
	scenario: UserSheetScenario;
};

/** Fetches user details for the sheet with TanStack Query and an explicit retry policy. */
function useUserQuery({ scenario, userId }: UseUserQueryOptions): UseQueryResult<User, Error> {
	return useQuery<User, Error>({
		queryKey: [...USER_QUERY_KEY_ROOT, userId, scenario],
		queryFn: ({ signal }) => fetchUser({ userId, scenario, signal }),
		retry: (failureCount, error) => shouldRetryUserQuery({ error, failureCount }),
		retryDelay: USER_QUERY_RETRY_DELAY_MS,
		staleTime: 30_000,
		gcTime: 5 * 60_000,
	});
}

/** Options passed to the query retry policy. */
type ShouldRetryUserQueryOptions = {
	/** Number of failed attempts TanStack Query has observed. */
	failureCount: number;
	/** Error thrown by the query function. */
	error: Error;
};

/** Retries transient server failures while skipping terminal and cancelled requests. */
function shouldRetryUserQuery({ error, failureCount }: ShouldRetryUserQueryOptions): boolean {
	if (error instanceof RequestCancelledError) {
		return false;
	}

	if (error instanceof UserRequestError && error.status === 500) {
		return failureCount < SERVER_ERROR_RETRY_LIMIT;
	}

	return false;
}
```

## Mock API Appendix

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.

```ts
const SIMULATED_USER_API_LATENCY_MS = 1_500;

/** Scenario names used by the docs demo controls. */
export type UserSheetScenario = "success" | "not-found" | "server-error";

/** The typed user record returned by the demo API. */
export type User = {
	/** Stable user identifier from the backend. */
	id: string;
	/** Display name shown in the sheet header content. */
	name: string;
	/** Primary email address for the account. */
	email: string;
	/** Role within the current workspace. */
	role: string;
	/** Workspace name used to make the example feel like app data. */
	workspace: string;
	/** Billing plan attached to the workspace. */
	plan: string;
	/** ISO date string for when the user joined. */
	joinedAt: string;
	/** Human-readable recent activity summary. */
	lastActiveAt: string;
};

/** The HTTP status codes intentionally simulated by the demo. */
type UserRequestErrorStatus = 404 | 500;

/** Options for constructing a typed demo request error. */
type UserRequestErrorOptions = {
	/** HTTP status returned by the simulated endpoint. */
	status: UserRequestErrorStatus;
	/** User-facing or operator-facing error message. */
	message: string;
	/** Request identifier included in the inline error UI. */
	requestId: string;
};

/** Error shape used when the simulated endpoint returns a non-2xx response. */
export class UserRequestError extends Error {
	/** HTTP status returned by the simulated endpoint. */
	readonly status: UserRequestErrorStatus;
	/** Request identifier included in the inline error UI. */
	readonly requestId: string;

	/** Creates a typed request error that preserves HTTP metadata. */
	constructor({ message, requestId, status }: UserRequestErrorOptions) {
		super(message);
		this.name = "UserRequestError";
		this.requestId = requestId;
		this.status = status;
	}
}

/** Error used when TanStack Query aborts a request after the sheet unmounts. */
export class RequestCancelledError extends Error {
	/** Creates a cancellation error that the retry policy can ignore. */
	constructor() {
		super("The user details request was cancelled.");
		this.name = "RequestCancelledError";
	}
}

/** Options for delaying the simulated API response. */
type WaitForDemoApiOptions = {
	/** Delay duration in milliseconds. */
	durationMs: number;
	/** Abort signal passed by TanStack Query. */
	signal?: AbortSignal;
};

/** Waits before resolving so the sheet can show the pending state. */
function waitForDemoApi({ durationMs, signal }: WaitForDemoApiOptions): Promise<void> {
	return new Promise((resolve, reject) => {
		if (signal?.aborted) {
			reject(new RequestCancelledError());
			return;
		}

		const timeoutId = setTimeout(() => {
			signal?.removeEventListener("abort", handleAbort);
			resolve();
		}, durationMs);

		/** Cancels the simulated API delay when the query unmounts. */
		function handleAbort() {
			clearTimeout(timeoutId);
			signal?.removeEventListener("abort", handleAbort);
			reject(new RequestCancelledError());
		}

		signal?.addEventListener("abort", handleAbort, { once: true });
	});
}

/** Options accepted by the simulated user fetcher. */
type FetchUserOptions = {
	/** User identifier requested by the sheet. */
	userId: string;
	/** Demo scenario selected by the user. */
	scenario: UserSheetScenario;
	/** Abort signal passed by TanStack Query. */
	signal?: AbortSignal;
};

/** Simulates a production API client with latency, typed data, and HTTP errors. */
export async function fetchUser({ scenario, signal, userId }: FetchUserOptions): Promise<User> {
	await waitForDemoApi({ durationMs: SIMULATED_USER_API_LATENCY_MS, signal });

	if (scenario === "not-found") {
		throw new UserRequestError({
			status: 404,
			message: `No user exists for ${userId}.`,
			requestId: createDemoRequestId({ status: 404, userId }),
		});
	}

	if (scenario === "server-error") {
		throw new UserRequestError({
			status: 500,
			message: "The user service failed before returning profile data.",
			requestId: createDemoRequestId({ status: 500, userId }),
		});
	}

	return {
		id: userId,
		name: "Ada Lovelace",
		email: "ada.lovelace@example.com",
		role: "Founding Engineer",
		workspace: "Analytical Engines",
		plan: "Enterprise",
		joinedAt: "1843-07-08",
		lastActiveAt: "2 minutes ago",
	};
}

/** Options for creating a deterministic request id in the demo. */
type CreateDemoRequestIdOptions = {
	/** HTTP status returned by the simulated endpoint. */
	status: UserRequestErrorStatus;
	/** User identifier requested by the sheet. */
	userId: string;
};

/** Creates a stable request id so the error UI looks like real production output. */
function createDemoRequestId({ status, userId }: CreateDemoRequestIdOptions): string {
	return `req_${status}_${userId.replace(/[^a-z0-9]/gi, "").slice(-8)}`;
}
```

## Production Notes

- Keep the sheet UI in one component and keep API details behind `fetchUser` or your app's equivalent data client.
- Use the `signal` argument from TanStack Query's `queryFn` when your API client supports cancellation.
- Tune `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.
- Let `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.
- Prefer explicit query state branches here over `useSuspenseQuery`. Suspense works, but error recovery requires an additional reset boundary and is harder to copy into route-controlled sheets.
