What Is Stale-While-Revalidate?
TanStack Query (formerly React Query) is built on a simple insight: server data in a UI is always potentially stale. Instead of blocking the user while you re-fetch, show the cached data immediately and update it in the background. This is stale-while-revalidate, and it makes your app feel instant.
v5 Unified API
v5 flattened the options API. Most commonly-used options are now top-level:
// v4
const { data } = useQuery(["users", id], () => fetchUser(id), {
staleTime: 1000 * 60,
keepPreviousData: true,
});
// v5 — unified object, new option names
const { data } = useQuery({
queryKey: ["users", id],
queryFn: () => fetchUser(id),
staleTime: 1000 * 60,
placeholderData: "keepPrevious", // replaces keepPreviousData
});
queryOptions() for Type-Safe Reuse
Define queries once and reuse them in hooks and prefetch calls with full type inference:
// lib/queries/tasks.ts
import { queryOptions } from "@tanstack/react-query";
export const taskQueries = {
list: (projectId: string) =>
queryOptions({
queryKey: ["tasks", projectId],
queryFn: () => api.tasks.list(projectId),
staleTime: 30_000,
}),
detail: (taskId: string) =>
queryOptions({
queryKey: ["tasks", "detail", taskId],
queryFn: () => api.tasks.get(taskId),
}),
};
// In a component
const { data: tasks } = useQuery(taskQueries.list(projectId));
// In a server component for prefetch
await queryClient.prefetchQuery(taskQueries.list(projectId));
// In a mutation's onSuccess
queryClient.invalidateQueries(taskQueries.list(projectId));
Optimistic Updates
const createTask = useMutation({
mutationFn: (data: NewTask) => api.tasks.create(data),
onMutate: async (newTask) => {
// Cancel in-flight refetches
await queryClient.cancelQueries(taskQueries.list(projectId));
// Snapshot current data
const previous = queryClient.getQueryData(taskQueries.list(projectId).queryKey);
// Optimistically update
queryClient.setQueryData(
taskQueries.list(projectId).queryKey,
(old: Task[]) => [...old, { ...newTask, id: "temp", status: "pending" }]
);
return { previous };
},
onError: (err, vars, context) => {
// Roll back on error
queryClient.setQueryData(
taskQueries.list(projectId).queryKey,
context?.previous
);
},
onSettled: () => {
queryClient.invalidateQueries(taskQueries.list(projectId));
},
});
Suspense Mode
v5 stabilizes Suspense integration — no more experimental flags:
import { useSuspenseQuery } from "@tanstack/react-query";
function TaskList({ projectId }: { projectId: string }) {
// Throws a promise while loading — caught by parent Suspense
const { data: tasks } = useSuspenseQuery(taskQueries.list(projectId));
return <ul>{tasks.map(t => <li key={t.id}>{t.title}</li>)}</ul>;
}
// Parent
<Suspense fallback={<Skeleton />}>
<ErrorBoundary fallback={<Error />}>
<TaskList projectId={id} />
</ErrorBoundary>
</Suspense>
SSR With Next.js App Router
// app/projects/[id]/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";
import { taskQueries } from "@/lib/queries/tasks";
export default async function ProjectPage({ params }: { params: { id: string } }) {
const queryClient = new QueryClient();
await queryClient.prefetchQuery(taskQueries.list(params.id));
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<TaskList projectId={params.id} />
</HydrationBoundary>
);
}
TanStack Query vs SWR
| Feature | TanStack Query | SWR | |---------|---------------|-----| | Bundle size | ~13KB | ~4KB | | Mutations | Full API | Manual | | Optimistic updates | Built-in | Manual | | Devtools | First-class | Third-party | | Infinite queries | Built-in | Built-in | | Suspense | Stable | Experimental | | Learning curve | Medium | Low |
SWR is great for simple fetching. TanStack Query wins when you need mutations, optimistic updates, or complex cache management.
References: TanStack Query · GitHub · v5 migration