TanStack Query v5: The Server State Patterns That Actually Scale
TanStack Query (formerly React Query) v5 ships with a simpler API, better TypeScript, and patterns that prevent the data-fetching mistakes most apps make in production.
Why Most Apps Misuse Server State
The mistake is treating server state like client state:
// ❌ useState for server data — stale, unsynced, causes bugs
const [user, setUser] = useState(null);
useEffect(() => {
fetch('/api/user').then(r => r.json()).then(setUser);
}, []);
This gives you stale data, race conditions, no loading/error states, and no automatic refetching. TanStack Query handles all of it.
v5 Breaking Changes First
v5 dropped the isLoading / isInitialLoading confusion:
// v4 (confusing)
const { isLoading, isFetching } = useQuery(...)
// isLoading = true only on first fetch with no cached data
// isFetching = true whenever a request is in-flight
// v5 (clear)
const { isPending, isFetching } = useQuery(...)
// isPending = no data yet (replaces isLoading)
// isFetching = request in-flight (background refresh included)
The options object is now the single argument:
// v4
useQuery(['user', id], () => fetchUser(id), { staleTime: 60_000 })
// v5
useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
staleTime: 60_000,
})
Patterns That Scale
1. Query Key Factories
Don't scatter magic strings. Centralize query keys:
// lib/query-keys.ts
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
};
// Usage — invalidate all user queries after mutation:
queryClient.invalidateQueries({ queryKey: userKeys.all });
// Or just the detail for one user:
queryClient.invalidateQueries({ queryKey: userKeys.detail(userId) });
2. Optimistic Updates That Don't Break
const updateStatus = useMutation({
mutationFn: (vars: { id: string; status: string }) =>
api.tasks.update(vars),
onMutate: async (vars) => {
// Cancel in-flight refetches (avoid overwriting optimistic update)
await queryClient.cancelQueries({ queryKey: taskKeys.detail(vars.id) });
// Snapshot current value for rollback
const previous = queryClient.getQueryData(taskKeys.detail(vars.id));
// Optimistically update
queryClient.setQueryData(taskKeys.detail(vars.id), (old: Task) => ({
...old,
status: vars.status,
}));
return { previous };
},
onError: (err, vars, context) => {
// Roll back on failure
queryClient.setQueryData(taskKeys.detail(vars.id), context?.previous);
},
onSettled: (data, err, vars) => {
// Always sync with server after mutation
queryClient.invalidateQueries({ queryKey: taskKeys.detail(vars.id) });
},
});
3. Suspense Mode (v5 First-Class)
// No loading state juggling — Suspense handles it
function UserProfile({ id }: { id: string }) {
const { data: user } = useSuspenseQuery({
queryKey: userKeys.detail(id),
queryFn: () => api.users.get(id),
});
return <div>{user.email}</div>; // user is always defined here
}
// Parent wraps in Suspense
export default function Page() {
return (
<Suspense fallback={<Skeleton />}>
<ErrorBoundary fallback={<ErrorState />}>
<UserProfile id="123" />
</ErrorBoundary>
</Suspense>
);
}
4. Prefetching for Instant Navigation
// app/dashboard/layout.tsx (Server Component)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
export default async function DashboardLayout({ children }) {
const queryClient = new QueryClient();
// Prefetch on the server — client gets it instantly
await queryClient.prefetchQuery({
queryKey: userKeys.detail('me'),
queryFn: () => api.users.me(),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
{children}
</HydrationBoundary>
);
}
5. Infinite Queries for Feeds
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['posts', filters],
queryFn: ({ pageParam }) => api.posts.list({ cursor: pageParam, ...filters }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
// Flat access to all pages
const posts = data?.pages.flatMap(page => page.items) ?? [];
Global Config Worth Setting
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000, // data fresh for 1 min before background refetch
gcTime: 5 * 60_000, // keep unused data in cache 5 min
retry: 1, // retry failed queries once (not 3x default)
refetchOnWindowFocus: false, // don't refetch every Alt+Tab (annoying in dev)
},
},
});
DevTools
npm install @tanstack/react-query-devtools
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
// Add inside your QueryClientProvider — only loads in development
<ReactQueryDevtools initialIsOpen={false} />
Shows every query key, status, data, and staleness in a panel. Invaluable for debugging cache behavior.
Ship Production-Ready Data Fetching
The AI SaaS Starter Kit includes TanStack Query v5 pre-configured with query key factories, optimistic update patterns, and server-side prefetching for Next.js App Router — skip the boilerplate.
$99 one-time → whoffagents.com
What v5 pattern has made the biggest difference in your app? Share below.
Top comments (0)