Data Fetching in React With TanStack Query

Server state vs client state, query keys as a public API, optimistic updates, and when SWR wins over TanStack Query. Lessons from production frontends.

The community feed in the creator-economy platform I worked at was the kind of screen you load once and then forget about. Until a backend incident one Tuesday morning made the p99 climb past 8 seconds and every retry from our React clients made the war room thread louder. The thing that haunted me later wasn’t the database. It was the way our frontend behaved when the server got slow. Spinners forever. Stale lists shown next to fresh comments. Two clicks producing three POSTs. The data layer was bad, and bad in a specific, fixable way.

That fix is what this article is about. And honestly, most “how to fetch data in React” content gets this backwards, so I’ll just say my position up top: in a real React app, server state and client state are two different things and you should not try to put them in the same store. TanStack Query treats them differently. Use it.

Server state is not client state

This part trips people up coming from Redux. Server state is owned by the server. You cache it on the client, but you don’t own it. It goes stale, it gets invalidated by other users, it can be refetched. Client state, by contrast, is the toggle on a modal, the contents of a text input, the selected tab. You own that one fully. Treating them as the same thing is how you end up with a 2,000-line Redux slice that mostly just mirrors API responses.

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

const Post = z.object({
  id: z.string(),
  authorId: z.string(),
  body: z.string(),
  createdAt: z.string().datetime(),
});
const PostList = z.array(Post);

export function useCommunityPosts(communityId: string) {
  return useQuery({
    queryKey: ["community", communityId, "posts"],
    queryFn: async ({ signal }) => {
      const res = await fetch(`/api/communities/${communityId}/posts`, { signal });
      if (!res.ok) throw new Error(`posts ${res.status}`);
      return PostList.parse(await res.json());
    },
    staleTime: 30_000,
    gcTime: 5 * 60_000,
  });
}

Three things that matter here and not much else. The queryKey is the cache identity. signal makes cancelation free when the component unmounts. staleTime controls when a refetch is even considered. Skip staleTime and you’ll refetch on every focus, which sounds fine until your support page mounts ten queries and your API tier hates you.

Query keys are a public API

I treat query keys the way I treat URL paths. They’re a contract. Once a key shape is in use across a few components, changing it casually will break cache invalidation in ways that look like “the new comment doesn’t show up sometimes”. Pick a shape, document it, and centralize it.

export const qk = {
  community: (id: string) => ["community", id] as const,
  communityPosts: (id: string) => ["community", id, "posts"] as const,
  communityPost: (id: string, postId: string) =>
    ["community", id, "post", postId] as const,
  me: () => ["me"] as const,
};

Hierarchical, predictable, easy to invalidate by prefix. queryClient.invalidateQueries({ queryKey: qk.community(id) }) blows away the community plus all its posts. That’s not a clever trick. That’s just how the library is designed and people keep ignoring it.

Optimistic updates without the footguns

This is where most teams get hurt. The naive pattern is: user clicks like, you fire the mutation, you bump the count in local state, you reconcile when the server responds. Works fine until the user clicks like four times in a row, your backend rate-limits the third one, and the count goes 12 -> 13 -> 14 -> 14 -> 13 across two re-renders. The reconcile step is where the bug lives.

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

export function useLikePost(communityId: string, postId: string) {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: async () => {
      const res = await fetch(`/api/posts/${postId}/like`, { method: "POST" });
      if (!res.ok) throw new Error(`like ${res.status}`);
      return res.json();
    },
    onMutate: async () => {
      await qc.cancelQueries({ queryKey: qk.communityPosts(communityId) });
      const prev = qc.getQueryData<Post[]>(qk.communityPosts(communityId));
      qc.setQueryData<Post[]>(qk.communityPosts(communityId), (old) =>
        (old ?? []).map((p) =>
          p.id === postId ? { ...p, likes: (p.likes ?? 0) + 1, likedByMe: true } : p,
        ),
      );
      return { prev };
    },
    onError: (_err, _vars, ctx) => {
      if (ctx?.prev) qc.setQueryData(qk.communityPosts(communityId), ctx.prev);
    },
    onSettled: () => {
      qc.invalidateQueries({ queryKey: qk.communityPosts(communityId) });
    },
  });
}

cancelQueries is the part everyone skips and then wonders why their optimistic update gets stomped two seconds later by a slow GET that was already in flight. Cancel first. Snapshot. Mutate the cache. Roll back on error. Invalidate on settled. That order is the whole pattern.

Cache keys are part of your public surface

Another one. At a live-video creator platform I led engineering at, we served creator profile pages at the edge with dynamic Open Graph metadata that varied by locale. A worker version shipped on a Wednesday that tightened cache key composition. What actually happened: the new key dropped the locale segment. EU users started seeing US users’ OG previews on shared links. I hit the global cache purge, which repopulated within three minutes with the same wrong key, producing the same wrong results. The real fix was a worker rollback plus a deploy-time check that diffs cache-key composition. Took about forty minutes of mis-shared previews. The takeaway translates one to one to TanStack Query: cache keys are part of your public API. Change them like you’d change a schema, not like you’d change a variable name.

When SWR wins over TanStack Query

I’ll keep this short because both libraries are good. SWR is lighter, simpler, and if all you need is useSWR("/api/me", fetcher) with revalidateOnFocus, just use SWR. The moment you need optimistic updates, mutations with rollback, paginated infinite lists, query cancellation, or a real cache hierarchy, TanStack Query wins. The shape of the library matches the shape of those problems. SWR can do most of it with effort. TanStack Query doesn’t make you fight for it.

Where I draw the line in practice: marketing site or thin dashboard, SWR. Product surface with mutations and offline-ish behavior, TanStack Query. There’s no third answer.

Takeaways

  • Treat server state and client state as different kinds of state. Don’t put server data in your global store.
  • Query keys are a contract. Centralize them, version them, invalidate them by prefix.
  • Optimistic updates without cancelQueries are a race condition with a UI on top.
  • staleTime and gcTime are not advanced features, they’re the only knobs that keep your API tier alive.
  • SWR for simple. TanStack Query for anything with mutations.
  • Cache keys are a public API. Diff them at deploy time.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.