state-handling
Standard patterns for loading, error, and empty states in React with TanStack Query. Covers query state (Loading → Error → Empty → Data), Skeleton/ErrorState/EmptyState components, mutation state, Sus
SKILL.mdskills/state-handling/SKILL.md
GitHubState Handling — React + TanStack Query
<when_to_use>- Building components that fetch data with
useQueryoruseSuspenseQuery - Implementing loading skeletons, error states, or empty states
- Setting up Suspense boundaries (single or parallel)
- Server prefetching with
HydrateClientto avoid loading spinners on first paint - Deduplicating RSC data fetches with
React.cache() - Reviewing mutation forms for proper pending/error handling
NOT for: routing, caching strategies, Server Actions, auth (use nextjs-app-router skill) NOT for: useEffect decisions, React 19 APIs (use react-dev skill)
</when_to_use>Query State Pattern
The canonical order — always handle states in this sequence:
function EntityList() {
const { data, isLoading, error, refetch } = useQuery(
trpc.entity.getAll.queryOptions(),
);
// 1. Loading state first
if (isLoading) return <Skeleton />;
// 2. Error state second
if (error) return <ErrorState message={error.message} onRetry={refetch} />;
// 3. Empty state third
if (!data?.length) return <EmptyState onCreate={handleCreate} />;
// 4. Success state last
return <DataList items={data} />;
}
Component Patterns
Skeleton
Match the layout of the loaded content — same number of rows, similar height:
function EntityListSkeleton() {
return (
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-lg bg-muted" />
))}
</div>
);
}
ErrorState
interface ErrorStateProps {
message: string;
onRetry?: () => void;
}
function ErrorState({ message, onRetry }: ErrorStateProps) {
return (
<div className="flex flex-col items-center gap-4 py-12">
<AlertCircle className="h-12 w-12 text-destructive" />
<p className="text-muted-foreground">{message}</p>
{onRetry && (
<Button variant="outline" onClick={onRetry}>
Try Again
</Button>
)}
</div>
);
}
EmptyState
interface EmptyStateProps {
title?: string;
description?: string;
onCreate?: () => void;
}
function EmptyState({
title = "No items yet",
description = "Get started by creating your first item.",
onCreate,
}: EmptyStateProps) {
return (
<div className="flex flex-col items-center gap-4 py-12">
<Inbox className="h-12 w-12 text-muted-foreground" />
<div className="text-center">
<h3 className="font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
{onCreate && <Button onClick={onCreate}>Create First Item</Button>}
</div>
);
}
Mutation State Pattern
function CreateEntityForm() {
const queryClient = useQueryClient();
const { mutate, isPending, error } = useMutation(
trpc.entity.create.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: trpc.entity.getAll.queryKey(),
});
toast.success("Entity created");
},
}),
);
return (
<form onSubmit={handleSubmit}>
{/* Form fields */}
{error && <p className="text-sm text-destructive">{error.message}</p>}
<Button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create"}
</Button>
</form>
);
}
Suspense Pattern
Use useSuspenseQuery + a Suspense boundary to eliminate explicit loading checks in the child:
// Parent component with Suspense boundary
function EntityPage() {
return (
<Suspense fallback={<EntityListSkeleton />}>
<EntityList />
</Suspense>
);
}
// Child uses useSuspenseQuery — no loading check needed
function EntityList() {
const { data } = useSuspenseQuery(trpc.entity.getAll.queryOptions());
if (!data.length) return <EmptyState />;
return <DataList items={data} />;
}
Next.js: Public RSC + ISR (Dual-Context Split)
Codified from a production T3 monorepo pattern. A representative implementation belongs in
apps/nextjs/src/trpc/server.tsx.
Next.js auto-promotes any page that reads headers() / cookies() to Dynamic, defeating export const revalidate = N (ISR). For T3 projects shipping ISR-cached public pages (marketing, blog, public event listings), the tRPC RSC context MUST be split into two factories:
// apps/nextjs/src/trpc/server.tsx shape:
import { cache } from "react";
import { headers } from "next/headers";
// AUTH-RESOLVING: reads headers, resolves session, blocks ISR
export const createContext = cache(async () => {
const heads = await headers();
const auth = await initAuth(heads);
return { auth, db, /* ... */ };
});
// AUTH-SKIPPING: synthesizes empty headers, never blocks ISR
export const createPublicContext = cache(async () => {
return { auth: null, db, /* ... */ };
});
// Two proxies share the same getQueryClient
export const trpc = createTRPCOptionsProxy({ /* uses createContext */ });
export const publicTrpc = createTRPCOptionsProxy({ /* uses createPublicContext */ });
Rule: Any T3 project shipping ISR public pages MUST split the RSC context. Public-route RSCs MUST import from publicTrpc, not trpc. The failure mode is silent: page logs as f Dynamic instead of o Static and ISR cache never warms.
Detection: Run pnpm build and grep the route table for o (static) vs f (dynamic) markers on any route with export const revalidate. A revalidate route showing f means the seam was broken — usually because someone imported trpc instead of publicTrpc from a public RSC.
Why this is non-obvious: The headers() read happens transitively through tRPC's createCaller, not in the page file itself. Engineers trace revalidate → page → tRPC call and see no obvious headers/cookies usage. The fix only becomes clear after a production incident where ISR pages started taking 800ms instead of 50ms (Next.js silently downgraded them to Dynamic).
Next.js: Server Prefetch Pattern
Prefetch on the server so the client renders from cache on first paint — no loading spinner on initial load.
// app/entities/page.tsx (RSC)
import { HydrateClient, trpc } from "@/trpc/server";
export default async function Page() {
// Fire-and-forget: prefetches into the dehydrated state passed to HydrateClient
void trpc.entity.getAll.prefetch();
return (
<HydrateClient>
<Suspense fallback={<EntityListSkeleton />}>
<EntityList />
</Suspense>
</HydrateClient>
);
}
// Client component — useQuery resolves from cache synchronously on first render
"use client";
function EntityList() {
const { data } = useSuspenseQuery(trpc.entity.getAll.queryOptions());
if (!data.length) return <EmptyState />;
return <DataList items={data} />;
}
Caveat — this only works if NO
"use client"layout gates the subtree. If any ancestor layout is a client component that defers{children}behind client-only state (e.g. an auth/burn gate usinguseState(null)+ auseEffect), the island never renders server-side and hydration is deferred to the client — so the feed is absent from raw SSR HTML anduseSuspenseQueryraces hydration into one stray first-paint refetch (refetchOnMount: falsealone won't close it; addinitialData). The prefetch still ships settled data, but "no loading spinner / zero refetch" needs the gating layout to be a server component. Full diagnosis + tell: seenextjs-app-routerskill § Client-component layouts kill RSC SSR + prefetch.
Split independent data into separate Suspense boundaries so they stream concurrently instead of sequentially.
// Sequential — Orders waits on UserProfile to settle before fetching
<Suspense fallback={<Skeleton />}>
<UserProfile />
<Orders />
</Suspense>
// Parallel — both stream independently
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile />
</Suspense>
<Suspense fallback={<OrdersSkeleton />}>
<Orders />
</Suspense>
Combine with server prefetching to start both fetches before the page renders:
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
void trpc.user.getById.prefetch({ id });
void trpc.order.getByUser.prefetch({ userId: id });
return (
<HydrateClient>
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile userId={id} />
</Suspense>
<Suspense fallback={<OrdersSkeleton />}>
<Orders userId={id} />
</Suspense>
</HydrateClient>
);
}
Next.js: React.cache() Deduplication
When multiple RSC components in one render tree fetch the same data,
React.cache() executes the underlying call only once per request.
// lib/queries/entity.ts
import { cache } from "react";
import { db } from "@{workspace}/db/client";
export const getEntity = cache(async (id: string) => {
return db.query.entity.findFirst({ where: (t, { eq }) => eq(t.id, id) });
});
// EntityHeader and EntityDetail both call getEntity(id) — one DB round-trip
Use this for server-only queries. Client-side dedup is handled by TanStack Query's built-in request deduplication.
Anti-Patterns
| Avoid | Why It Fails | Do Instead |
|---|---|---|
data && data.map(...) |
An empty array is truthy in JS, so data && data.map(...) renders [] — the map produces nothing, but the empty-state branch never runs and the user sees a blank div instead of an "add your first item" prompt. |
Check !data?.length for empty |
| Nested ternaries | Each nested ? : forces the reader to hold every prior branch's condition in their head to know which one currently applies — a state bug (e.g. showing stale data during a background refetch) hides inside a branch nobody re-reads carefully. |
Sequential if returns |
| Loading spinner in every component | Every component that owns its own spinner mounts and unmounts independently as its query settles, so a page with 3 queries flashes 3 spinners at 3 different times instead of one coordinated fallback — visible layout jank, not just "less clean code". | Suspense boundaries |
| Inline error messages | Each call site re-decides styling, retry affordance, and copy from scratch, so error UX silently drifts (some show a retry button, some don't, some use red text, some don't) as the component count grows. | Dedicated ErrorState component |
isLoading ? <Spinner /> : data.map() |
This two-branch check has no error branch — when the query fails, isLoading is false and data is undefined, so data.map() either throws or (with optional chaining) silently renders as if the list were empty. A real fetch failure looks identical to "no data yet". |
Full state pattern (Loading → Error → Empty → Data) |
useQuery loading spinner on page load |
Without a server prefetch, the client issues the fetch after hydration, so first paint always blocks on a round-trip the server already had time to make — a spinner appears on every cold load even though the data was fetchable before the page ever reached the browser. | Server prefetch + HydrateClient |
Single <Suspense> for unrelated data |
One boundary serializes rendering of everything inside it — the whole subtree waits for the slowest fetch to settle before any of it paints, even when the fetches are otherwise fully independent (e.g. UserProfile blocks Orders from appearing). |
Parallel Suspense boundaries |
| Duplicate DB calls across RSC tree | Without cache(), every component that needs the same record (e.g. EntityHeader and EntityDetail both calling getEntity(id)) issues its own DB round-trip — the query count for one request scales with how many components read the data, not with how much data there is. |
React.cache() per-request dedup |
Related Skills
react-dev— useEffect decisions, React 19 APIs, generic components, Server Actionsnextjs-app-router— routing, caching strategies (fetch(), ISR,unstable_cache), middleware, authtrpc-patterns—queryOptions()/mutationOptions()wrappers (required for the patterns above)