DEV Community

Atlas Whoff
Atlas Whoff

Posted on

TanStack Query v5: The Server State Patterns That Actually Scale

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);
}, []);
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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,
})
Enter fullscreen mode Exit fullscreen mode

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) });
Enter fullscreen mode Exit fullscreen mode

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) });
  },
});
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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) ?? [];
Enter fullscreen mode Exit fullscreen mode

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)
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

DevTools

npm install @tanstack/react-query-devtools
Enter fullscreen mode Exit fullscreen mode
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

// Add inside your QueryClientProvider — only loads in development
<ReactQueryDevtools initialIsOpen={false} />
Enter fullscreen mode Exit fullscreen mode

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)