Diagram showing TanStack Query and Zustand split for server and client state

Combining TanStack Query with Zustand Atomic State

TanStack Query and Zustand solve two completely different problems, and mixing them up is the fastest way to end up with a bloated, hard-to-debug React store. As React applications scale, state management often turns messy. A common anti-pattern in large codebases is dumping everything into a single global store Redux, or a massive Zustand slice including API response payloads, loading flags, error strings, selected UI tabs, and sidebar toggle booleans.

That approach creates real friction:

  • Stale server cache – manually managing network responses inside a global store forces you to reimplement caching, invalidation, retries, and deduplication by hand
  • Store bloat – store files balloon into thousands of lines of reducer and action logic just to set isLoading: true or data: response.json
  • Re-render cascades – unrelated global store updates trigger re-renders in components that don’t even touch the mutated data

The fix is a clean separation of concerns: use TanStack Query exclusively for server state (async, remote, cached data) and Zustand exclusively for client state (synchronous, ephemeral, local UI state). This guide walks through exactly how to pair TanStack Query and Zustand cleanly, with working code, so you can build faster and more maintainable React apps. Once you’ve set up TanStack Query and Zustand this way, most of the boilerplate that used to live in a single bloated store simply disappears.

Also Read: Declarative Data Fetching with TanStack Query

How TanStack Query and Zustand Divide Responsibilities

The TanStack Query and Zustand Boundary at a Glance

Before writing any code, it helps to see the boundary laid out side by side.

TanStack Query vs Zustand | Server State vs Client State Comparison

TanStack Query vs Zustand

Side-by-side comparison of server state vs client state management in React. Know when to reach for TanStack Query and when Zustand is the better fit.

Server State vs Client State: Responsibility Breakdown
Responsibility TanStack Query (Server State) Zustand (Client State)
Data origin Remote API / database Local browser memory
Examples User feeds, product lists, profiles Sidebar toggle, active tab, dark mode, draft inputs
Async handling Native promises, loading, retries Not meant for network calls
Cache lifetime Governed by staleTime and gcTime Persists until reload (or manual persist)
Primary identifier queryKey array A named store hook (useStore)

Both libraries complement each other. Use them together for a complete state strategy. Last updated: August 2026.

Once you enforce this boundary, Zustand stores stay thin and fast synchronous values and actions only while TanStack Query owns the entire cache lifecycle: freshness, deduplication, and garbage collection. This is the boundary that makes TanStack Query and Zustand worth adopting together instead of reaching for a single do-everything store.

Visual boundary between TanStack Query server state and Zustand client state
Combining TanStack Query with Zustand Atomic State 7

Step 1: Define the Zustand Client Store

Start with a clean Zustand store that manages UI search filters and display settings for an e-commerce dashboard. Notice it contains zero network-fetching code and zero API response fields that’s the whole point of keeping TanStack Query and Zustand separate. Any time you’re tempted to add a fetch call inside a Zustand store, that’s the signal you’ve blurred the TanStack Query and Zustand boundary you’re trying to build.

javascript

// src/stores/useFilterStore.js
import { create } from 'zustand';

export const useFilterStore = create((set) => ({
  // Local UI State
  searchQuery: '',
  category: 'all',
  sortBy: 'price-asc',
  viewMode: 'grid', // 'grid' | 'list'

  // Actions
  setSearchQuery: (query) => set({ searchQuery: query }),
  setCategory: (category) => set({ category }),
  setSortBy: (sortBy) => set({ sortBy }),
  toggleViewMode: () =>
    set((state) => ({ viewMode: state.viewMode === 'grid' ? 'list' : 'grid' })),
  resetFilters: () => set({ searchQuery: '', category: 'all', sortBy: 'price-asc' }),
}));

Every field here is something the user changed with a click or a keystroke. Nothing here came from a server response that distinction is the entire reason TanStack Query and Zustand work well together instead of fighting each other.

Step 2: Connect Zustand State to TanStack Query

The trick isn’t copying Zustand state into TanStack Query or vice versa. Instead, read the Zustand state inside your custom query hook and pass those values straight into the queryKey.

Because TanStack Query automatically refetches whenever anything in the queryKey array changes, updating a Zustand filter triggers a fresh API call automatically no manual useEffect wiring required.

javascript

// src/hooks/useProducts.js
import { useQuery } from '@tanstack/react-query';
import { useFilterStore } from '../stores/useFilterStore';

async function fetchProducts({ searchQuery, category, sortBy }) {
  const params = new URLSearchParams({
    q: searchQuery,
    category,
    sort: sortBy,
  });

  const res = await fetch(`/api/products?${params.toString()}`);
  if (!res.ok) throw new Error('Failed to fetch products');
  return res.json();
}

export function useProducts() {
  // 1. Read atomic values from the Zustand client store
  const searchQuery = useFilterStore((state) => state.searchQuery);
  const category = useFilterStore((state) => state.category);
  const sortBy = useFilterStore((state) => state.sortBy);

  // 2. Pass Zustand values directly into the queryKey array
  return useQuery({
    queryKey: ['products', { searchQuery, category, sortBy }],
    queryFn: () => fetchProducts({ searchQuery, category, sortBy }),
    staleTime: 1000 * 60 * 2, // Data stays fresh for 2 minutes
  });
}

This is the actual bridge between TanStack Query and Zustand: Zustand owns the filter values, TanStack Query owns the fetch that reacts to them. Nowhere in this hook do you manually sync the two the queryKey array does that work for you.

Step 3: Build the UI Component

With the store and the query hook in place, the component itself stays remarkably clean. It consumes Zustand for UI interactions and TanStack Query for data display and never has to reconcile the two manually. This is what a proper TanStack Query and Zustand split looks like once it reaches the component layer: two clearly separated data sources, zero manual glue code.

Data flow diagram of TanStack Query and Zustand feeding a React component
Combining TanStack Query with Zustand Atomic State 8

jsx

// src/components/ProductCatalog.jsx
import React from 'react';
import { useFilterStore } from '../stores/useFilterStore';
import { useProducts } from '../hooks/useProducts';

export function ProductCatalog() {
  // Zustand state & actions (client UI)
  const { searchQuery, category, viewMode, setSearchQuery, setCategory, toggleViewMode } =
    useFilterStore();

  // TanStack Query hook (server data)
  const { data: products, isLoading, isError, error } = useProducts();

  return (
    <div className="p-6">
      {/* Search & filter controls, driven by Zustand */}
      <div className="flex gap-4 mb-6">
        <input
          type="text"
          value={searchQuery}
          onChange={(e) => setSearchQuery(e.target.value)}
          placeholder="Search products..."
          className="border p-2 rounded"
        />

        <select value={category} onChange={(e) => setCategory(e.target.value)}>
          <option value="all">All Categories</option>
          <option value="electronics">Electronics</option>
          <option value="clothing">Clothing</option>
        </select>

        <button onClick={toggleViewMode} className="bg-gray-200 px-4 py-2 rounded">
          View: {viewMode.toUpperCase()}
        </button>
      </div>

      {/* Server data display, driven by TanStack Query */}
      {isLoading && <div>Loading products...</div>}
      {isError && <div className="text-red-500">Error: {error.message}</div>}

      {products && (
        <div className={viewMode === 'grid' ? 'grid grid-cols-3 gap-4' : 'flex flex-col gap-2'}>
          {products.map((product) => (
            <div key={product.id} className="border p-4 rounded shadow">
              <h3 className="font-bold">{product.name}</h3>
              <p>${product.price}</p>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

Notice what’s absent: no useEffect syncing server data into Zustand, no manual loading/error state tracking, no risk of the two stores drifting out of sync.

Why Splitting TanStack Query and Zustand Works So Well

  • Zero data-synchronization bugs. You never copy server response fields into Zustand, so there’s nothing to keep manually in sync when a mutation happens.
  • Fine-grained re-renders. Zustand selectors mean components re-render only when their specific picked slice changes. TanStack Query re-renders components only when the cache actually updates.
  • Decoupled testing. You can test filter logic in isolation with simple Zustand state assertions, and test API integration separately using TanStack Query’s testing utilities.

Keeping TanStack Query and Zustand in their own lanes also makes onboarding easier a new developer opening the codebase immediately knows where to look: Zustand for anything the user clicked, TanStack Query for anything that came from the network. Teams that skip this and let TanStack Query and Zustand responsibilities blur together are usually the ones rewriting their state layer a year later.

Common Mistakes When Mixing TanStack Query and Zustand

Most TanStack Query and Zustand problems trace back to one of three habits carried over from single-store thinking.

Illustration of a tangled store representing a TanStack Query and Zustand anti-pattern
Combining TanStack Query with Zustand Atomic State 9
  • Storing fetched data in Zustand “just to be safe.” This reintroduces the exact staleness problem TanStack Query exists to solve. A TanStack Query and Zustand setup only works if fetched data stays out of the store entirely.
  • Putting isLoading flags in Zustand manually. TanStack Query already tracks loading and error state per query a hand-rolled isLoading boolean in Zustand is redundant and will eventually drift out of sync with the real request state.
  • Using Zustand’s persist middleware for server data. Persisting API responses to localStorage defeats TanStack Query’s cache invalidation entirely. Reserve persist for genuine user preferences like theme or layout. Each of these mistakes has the same root cause: not committing fully to the TanStack Query and Zustand boundary once it’s set up.

Frequently Asked Questions

Can I use Redux instead of Zustand with TanStack Query?
Yes the same separation-of-concerns principle applies whether you pair TanStack Query with Redux, Jotai, or Zustand. TanStack Query still owns server state; the client-state library owns the rest. TanStack Query and Zustand is simply the lighter-weight combination and requires far less boilerplate.

Does Zustand need middleware to work well with TanStack Query?
No middleware is required for the core TanStack Query and Zustand pattern shown here. Zustand’s persist middleware is optional and should be reserved for genuine client preferences, not server data.

What happens if I put server data in a Zustand store anyway?
You lose TanStack Query’s automatic cache invalidation, deduplication, and background refetching for that data, and you take on the job of manually keeping it in sync the exact problem a proper TanStack Query and Zustand split is designed to avoid.

Do I need to rewrite my entire store to adopt this pattern?
No. TanStack Query and Zustand can be introduced incrementally migrate one data-fetching hook at a time out of your existing global store rather than rewriting everything at once.

Conclusion

Pairing TanStack Query and Zustand for React state management gives you a clean, well-understood boundary: TanStack Query owns anything that comes from a server, Zustand owns anything that lives only in the browser. Keeping that boundary strict eliminates hundreds of lines of boilerplate, prevents rendering bottlenecks, and makes the codebase easier for anyone else to pick up. Once you’ve made the switch, it’s hard to go back to a codebase that doesn’t split TanStack Query and Zustand this cleanly.

If your app currently stores API data in Zustand or Redux, start by migrating your highest-traffic data fetch to a TanStack Query hook first that single change usually removes the most boilerplate for the least risk.

Content Protection by DMCA.com
Spread the love
Scroll to Top
×