Skip to main content
EJ Centeno

Server State Management with TanStack Query: Lessons from Enterprise Apps

February 24, 2026 · 5 min read

Server State Management with TanStack Query: Lessons from Enterprise Apps

Before I used TanStack Query, I managed server state the way most developers do when they first encounter React: useState for the data, useState for loading, useState for errors, useEffect to fetch, and a growing collection of edge cases that never quite felt right. Parallel requests, loading states for multiple endpoints, cache invalidation after mutations — it always ended up messier than it should.

TanStack Query (formerly React Query) changed how I think about this problem entirely. Here's what using it on a real enterprise application looks like.

The Core Insight

TanStack Query makes a fundamental distinction between server state and client state. Server state is data that lives on a server, is asynchronously loaded, can be stale, and can be modified by others. Client state is local — UI state, form values, selected tabs. These two categories have different requirements and should be managed differently.

Once I accepted this distinction, the architecture of our stock administration system became clearer. TanStack Query owns the server state. Zustand owns the client state. They rarely overlap, and when they do, the data flows in one direction.

Why Not useEffect?

The useEffect pattern for data fetching has a long list of problems. It doesn't deduplicate requests — two components fetching the same endpoint make two network calls. It doesn't cache results, so navigating away from a page and back triggers a full re-fetch. It doesn't handle background refetching, so data grows stale without any mechanism to refresh it. And it's difficult to coordinate loading and error states across multiple components.

With TanStack Query, two components using the same query key share a single request and the same cached result. The first component that mounts triggers the fetch; the second component just subscribes to the existing cached data. This deduplication alone eliminated several unnecessary API calls in our app.

Query Keys and Cache Management

Query keys are how TanStack Query identifies and organizes cached data. We use a consistent structure across the app: [entity, identifier, filters]. For example, fetching a user's ESPP grants with a specific status filter uses a key like ['espp-grants', userId, { status: 'active', year: 2026 }].

When filters change, the key changes, and a new request fires automatically. When we need to invalidate cached data after a mutation, we invalidate by prefix. Calling queryClient.invalidateQueries with the base key marks all cached queries whose key starts with 'espp-grants' as stale and triggers a background refetch for any that are currently in use.

Stale Time and Background Refetching

We configure staleTime per query based on how frequently the data changes. Reference data like plan configurations doesn't change often — we set a stale time of 10 minutes. Participant account balances change more frequently — we use a shorter stale time or enable refetch on window focus.

Background refetching is one of TanStack Query's best features. When a user returns to a tab after being away, data marked as stale is automatically refetched in the background. The UI shows the cached data immediately (no loading spinner) and updates once the fresh data arrives.

Optimistic Updates

For some mutations, we apply optimistic updates so the UI responds immediately without waiting for the server. When a user updates their ESPP contribution rate, we update the UI instantly and roll back if the mutation fails.

The pattern uses onMutate to cancel in-flight queries for the affected data, snapshot the current cached value, and apply the optimistic update. onError rolls back to the snapshot. onSettled invalidates the query to ensure we eventually sync with the server's actual state.

Optimistic updates require confidence that your mutation will succeed. We only use them for simple updates where the failure rate is low and the UX benefit of immediate feedback is high.

Combining with Zustand

Our app has two kinds of state. Server data — grants, participants, plan configurations — lives in TanStack Query. UI-driven client state — the currently selected plan period, the active modal, filter panel visibility — lives in Zustand.

We don't mix these. But Zustand state sometimes feeds into query keys. The selected plan period is Zustand state that becomes part of the query key for fetching plan data. This creates a clean unidirectional flow: user action updates Zustand state, Zustand change updates the query key, TanStack Query automatically refetches with the new parameters.

TanStack Query isn't magic, but it's the right abstraction for server state. Once you use it, going back to manual useEffect fetching feels like going back to callbacks after discovering promises.

← Back to all posts