TanStack Query (formerly React Query) is the closest thing to a default in React for server-state. It survived framework wars, RSC, and continued growing. This post is the working set.

The core idea

Server state is fundamentally different from UI state:

  • It’s remote (you don’t own it).
  • It’s async.
  • It can be stale (someone else changed it).
  • It needs invalidation.

TanStack Query owns the server state lifecycle. UI state belongs elsewhere.

Setup

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            staleTime: 30 * 1000,
            retry: 2,
        },
    },
});

function App() {
    return (
        <QueryClientProvider client={queryClient}>
            <YourApp />
        </QueryClientProvider>
    );
}

staleTime: 30s means data is considered fresh for 30s; no refetch in that window.

Queries

import { useQuery } from "@tanstack/react-query";

function UserProfile({ id }: { id: string }) {
    const { data, isLoading, error } = useQuery({
        queryKey: ["user", id],
        queryFn: () => api.getUser(id),
    });
    
    if (isLoading) return <Skeleton />;
    if (error) return <Error error={error} />;
    return <Profile user={data} />;
}

queryKey is the cache key. Identical keys share cached data; different keys are separate.

Query keys discipline

Use object-style keys for clarity:

queryKey: ["users", "list", { page: 1, limit: 20, filter: "active" }]
queryKey: ["users", "detail", id]
queryKey: ["users", id, "posts"]

Hierarchical. Easy to invalidate sub-trees.

For type safety, the factory pattern :

const userKeys = {
    all: ["users"] as const,
    list: (filter: string) => [...userKeys.all, "list", filter] as const,
    detail: (id: string) => [...userKeys.all, "detail", id] as const,
};

Centralized; refactor-friendly.

Mutations

import { useMutation, useQueryClient } from "@tanstack/react-query";

function CreatePost() {
    const qc = useQueryClient();
    const mutation = useMutation({
        mutationFn: (data: NewPost) => api.createPost(data),
        onSuccess: () => {
            qc.invalidateQueries({ queryKey: ["posts"] });
        },
    });
    
    return <button onClick={() => mutation.mutate({ title: "..." })}>Create</button>;
}

After mutation: invalidate dependent queries. They refetch.

Optimistic updates

const mutation = useMutation({
    mutationFn: (newPost) => api.createPost(newPost),
    
    // Optimistic
    onMutate: async (newPost) => {
        await qc.cancelQueries({ queryKey: ["posts"] });
        const previous = qc.getQueryData(["posts"]);
        qc.setQueryData(["posts"], (old) => [...old, { ...newPost, id: "temp" }]);
        return { previous };
    },
    
    onError: (err, newPost, context) => {
        qc.setQueryData(["posts"], context.previous);
    },
    
    onSettled: () => {
        qc.invalidateQueries({ queryKey: ["posts"] });
    },
});

UI updates instantly; rolls back on error; reconciles on success.

Prefetching

// On hover
<Link
    to={`/users/${id}`}
    onMouseEnter={() => qc.prefetchQuery({
        queryKey: userKeys.detail(id),
        queryFn: () => api.getUser(id),
    })}
>

Or in a route loader / RSC:

// Server-side prefetch
await qc.prefetchQuery({ queryKey: userKeys.detail(id), queryFn: ... });
const dehydrated = dehydrate(qc);
// Send dehydrated to client; rehydrate

Page navigation feels instant.

Pagination

const { data } = useQuery({
    queryKey: ["posts", { page, limit: 20 }],
    queryFn: () => api.getPosts(page, 20),
    placeholderData: keepPreviousData,
});

keepPreviousData keeps the old data visible while new data loads. No flicker.

For infinite scroll: useInfiniteQuery:

const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
    queryKey: ["posts"],
    queryFn: ({ pageParam = 0 }) => api.getPosts(pageParam, 20),
    getNextPageParam: (last, all) => last.next,
    initialPageParam: 0,
});

Real-time

For WebSocket / SSE pushed data:

useEffect(() => {
    const ws = new WebSocket(...);
    ws.onmessage = (e) => {
        const { type, data } = JSON.parse(e.data);
        if (type === "post.created") {
            qc.setQueryData(["posts"], (old) => [...old, data]);
        }
    };
    return () => ws.close();
}, []);

Push updates into the cache directly; or invalidate to trigger refetch.

Suspense mode

const { data } = useSuspenseQuery({
    queryKey: ["user", id],
    queryFn: () => api.getUser(id),
});
// data is non-nullable; component suspends until ready

// Wrapped:
<Suspense fallback={<Skeleton />}>
    <UserProfile id={id} />
</Suspense>

Cleaner than isLoading checks.

RSC integration

In Next.js App Router:

// app/users/[id]/page.tsx (Server Component)
async function UserPage({ params }) {
    const qc = new QueryClient();
    await qc.prefetchQuery({ queryKey: ["user", params.id], queryFn: () => fetchUser(params.id) });
    
    return (
        <HydrationBoundary state={dehydrate(qc)}>
            <UserProfile id={params.id} />  {/* Client Component using useQuery */}
        </HydrationBoundary>
    );
}

Server fetches; client hydrates with that data; subsequent interactions use the cache.

DevTools

import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

// in dev
<ReactQueryDevtools />

Shows every query, its state, when it last fetched. Indispensable while developing.

Common mistakes

1. queryKey as string

queryKey: `users-${id}`  // BAD: hard to invalidate hierarchically
queryKey: ["users", id]  // GOOD

2. Storing server state in useState

useState(initialFromServer) then keeping it in sync manually. TanStack Query is built for this.

3. Over-eager refetching

Default refetchOnWindowFocus: true can hammer your API. Tune staleTime per query.

4. Mutating without invalidation

UI shows stale data after a mutation. Always invalidate or set the cache directly.

5. Forgetting placeholderData

Pagination flickers between renders. keepPreviousData smooths it.

What I’d ship today

For React apps:

  • TanStack Query for all server state.
  • Zustand / Jotai / useState for UI state.
  • Query key factory pattern.
  • DevTools in dev.
  • Suspense queries for cleaner async UX.
  • RSC + hydration for first-paint perf.

Read this next

If you want my TanStack Query patterns library, it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .