Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 018 / 100

TanStack Query essentials for React & Next.js developers

Server state handled properly.

Expona Admin8 min read

After five years building production platforms, I've learned that server state management makes or breaks your app's UX and backend costs. TanStack Query essentials aren't optional—they're the foundation that keeps your data in sync without hammering your API or losing user trust. Let me walk you through what actually matters.

Why TanStack Query in the first place

Before we dig into specifics, understand the problem. Your React component needs data. You fetch it. The user navigates away. Another component needs the same data, so you fetch again. Now you have a stale value somewhere. A mutation succeeds but lists don't update. Loading states flicker. It's chaos.

TanStack Query (formerly React Query) solves this by treating server state as a first-class citizen. It caches, deduplicates, refetches, and synchronizes—all without you managing useEffect arrays or state machines. I've seen teams ship faster and with fewer bugs once they stopped fighting these patterns.

Query keys are everything

Query keys identify your cached data. They're not strings—they're hierarchical arrays. Every variable that affects the query result must be in the key. If your key is missing a dependency, you'll get stale data or cache collisions.

Real example: I'm building a leads page with filters. Status and page number both affect the API call. If I use the same key for different filters, TanStack Query won't know they're different queries—it'll return cached data from the first filter when I switch to the second.

tsx
// ❌ Wrong: ignores status and page
const { data } = useQuery({
  queryKey: ['leads'],
  queryFn: () => api.leads({ status, page }),
});

// ✅ Correct: includes every dependency
const { data } = useQuery({
  queryKey: ['leads', { status, page }],
  queryFn: () => api.leads({ status, page }),
  staleTime: 30_000,
});

The key structure matters too. Use objects for complex filters, nest logically:

tsx
// Product listing with filters and pagination
queryKey: ['products', { category, priceRange, sortBy }, page]

// User detail page—never combine user ID with other queries
queryKey: ['users', userId]

// Comments on a specific post
queryKey: ['posts', postId, 'comments', { limit, offset }]

When you fetch the same data across different components, they share the cache automatically. That's deduplication for free. But only if the keys match exactly.

staleTime vs gcTime: the distinction that saves you

These two settings do completely different things, and mixing them up wastes API calls or loses data unexpectedly.

staleTime (defaults to 0) decides how long data is considered "fresh." While data is fresh, TanStack Query won't refetch it, even if you request it from another component. Once staleTime expires, the data is "stale"—TanStack Query will refetch it the next time the query runs.

gcTime (garbage collection time, defaults to 5 minutes) decides how long to keep stale data in memory. If no component is using a query and gcTime expires, TanStack Query throws it away. On the next fetch, you'll hit the network again.

Practical scenario: A user loads the leads page (staleTime: 30 seconds). They switch to settings for 20 seconds, then back to leads. The data is still fresh—no refetch, instant return. They come back 40 seconds later—data is stale, so TanStack Query refetches. They navigate away for 6 minutes—gcTime expires, memory is freed.

tsx
// Leads query: fresh for 30s, keep in memory for 5 mins
const { data: leads } = useQuery({
  queryKey: ['leads', { status, page }],
  queryFn: () => api.leads({ status, page }),
  staleTime: 30_000,      // 30 seconds
  gcTime: 5 * 60 * 1000,  // 5 minutes (TanStack Query v5+)
});

// User profile: trust the server for 10 mins, then refetch
const { data: profile } = useQuery({
  queryKey: ['auth', 'profile'],
  queryFn: () => api.auth.profile(),
  staleTime: 10 * 60 * 1000, // 10 minutes
});

// Real-time data: always stale, always refetch
const { data: notifications } = useQuery({
  queryKey: ['notifications'],
  queryFn: () => api.notifications(),
  staleTime: 0, // Immediately stale
  refetchInterval: 3000, // Refetch every 3 seconds
});

Set staleTime based on how frequently your data changes. If it's user-driven (profile, settings), longer staleTime. If it's real-time (notifications, prices), shorter or zero. Set gcTime based on your memory budget and user session patterns. Most apps are fine with the default 5 minutes.

Mutations invalidate and cascade updates

After a user creates, updates, or deletes something, queries need to know. TanStack Query provides invalidation—tell it which queries are now stale, and they'll refetch automatically.

Example: User creates a new lead. The API returns the new lead object. But you also have a leads list query that's been caching. Without invalidation, that list doesn't update. The user creates a lead, sees success, navigates to the list, and the new lead isn't there. Invalidation fixes this:

tsx
const queryClient = useQueryClient();

const createLeadMutation = useMutation({
  mutationFn: (newLead) => api.leads.create(newLead),
  onSuccess: (createdLead) => {
    // Option 1: Invalidate the entire leads query—refetch on next access
    queryClient.invalidateQueries({
      queryKey: ['leads'],
    });
    
    // Option 2: Manually update cache (faster, no network)
    queryClient.setQueryData(['leads'], (old) => [...old, createdLead]);
  },
});

const handleCreate = async (formData) => {
  try {
    await createLeadMutation.mutateAsync(formData);
    toast.success('Lead created');
  } catch (err) {
    toast.error(err.message);
  }
};

Invalidation is a broadcast. If I invalidate ['leads'], every query with that key gets marked stale. Every component showing a leads list refetches. That's beautiful for consistency but watch your network if you have many dependent queries.

For update mutations, you can be surgical:

tsx
const updateLeadMutation = useMutation({
  mutationFn: ({ id, updates }) => api.leads.update(id, updates),
  onSuccess: (updatedLead) => {
    // Update the detail page cache
    queryClient.setQueryData(['leads', updatedLead.id], updatedLead);
    
    // Invalidate any list that might include this lead
    queryClient.invalidateQueries({
      queryKey: ['leads'],
      exact: false, // Matches ['leads'], ['leads', {...}], etc.
    });
  },
});

Prefetch on hover for instant navigation

Prefetching is the polish layer. When a user hovers over a link, you fetch the data for that page in the background. When they click, the data is already there—instant load.

I use this on detail pages constantly. A user scrolls a leads list—as they hover each lead card, the detail query prefetches. They click, and the detail page renders immediately.

tsx
const queryClient = useQueryClient();

function LeadsListItem({ lead }) {
  const handleMouseEnter = () => {
    queryClient.prefetchQuery({
      queryKey: ['leads', lead.id],
      queryFn: () => api.leads.detail(lead.id),
      staleTime: 30_000,
    });
  };

  return (
    <div>
      <h3>{lead.name}</h3>
      <p>{lead.email}</p>
    </div>
  );
}

// On the detail page, the query is instant
function LeadDetail({ id }) {
  const { data, isLoading } = useQuery({
    queryKey: ['leads', id],
    queryFn: () => api.leads.detail(id),
  });

  if (isLoading) return <div>Loading...</div>;
  return <div>{data.name}</div>;
}

Prefetching is safe. If the user leaves before clicking, the prefetched data sits in cache and expires naturally. If they click, it's instant. Win either way.

Common mistakes

  • Missing variables in query keys. If your query depends on userId or filter values, they must be in the key. Otherwise, different parameter combinations will collide in cache.
  • Over-invalidating. Invalidating ['users'] when you only changed one user's email refetches all user queries. Be specific: invalidate ['users', userId] or use exact matching.
  • Setting staleTime too high. If your data changes frequently and staleTime is 30 minutes, users see stale data. It's tempting to reduce API load, but consistency matters more. Use refetchInterval for true polling needs.
  • Forgetting error handling. useQuery and useMutation both have error states. Check isError and error, not just isLoading and data. A failed query is not the same as a loading query.
  • Manual refetching without understanding cache. Calling refetch() directly is sometimes right, but often invalidateQueries is cleaner—it respects staleTime and deduplicates across components.
  • Prefetching without staleTime. If you prefetch with staleTime: 0, the data is stale immediately and refetches when the page loads anyway. Usually, prefetch should match the staleTime of the actual query.

Key takeaways

  • Query keys are hierarchical arrays that must include every variable affecting the query—the foundation of proper caching.
  • staleTime controls refetch timing (how long data is fresh); gcTime controls memory cleanup (how long to keep unused data).
  • Invalidate queries after mutations to keep cached lists and details in sync automatically across your app.
  • Prefetch on user intent (hover, focus) to create instant navigation and better perceived performance.
  • Be surgical with invalidation and explicit with query keys—loose matching leads to wasted refetches and confusion.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles