EmpireUI
Get Pro
← Blog7 min read#react#state-management#tanstack-query

TanStack Query vs Zustand: They're Not the Same Thing

TanStack Query and Zustand solve completely different problems. Here's how to pick the right tool — and why you'll probably need both in the same app.

Two terminal windows side by side on a dark monitor, showing JavaScript code and package manager output

Wait, Are These Even Competing?

Honestly, the number of Stack Overflow threads asking 'TanStack Query or Zustand?' suggests a lot of developers think these two libraries are alternatives to each other. They're not. Not even close. One manages server state — remote data that lives on a backend — and the other manages client state that lives entirely in your browser.

TanStack Query (formerly React Query, now at v5.59.0) is a data-fetching and caching library. You throw it an async function, and it handles loading states, background refetching, stale-while-revalidate, cache invalidation, and pagination. Zustand (v4.5.4 at the time of writing) is a bare-minimum global store. It holds whatever JavaScript values you put in it and lets any component subscribe to changes.

The confusion is understandable. Both libraries showed up as alternatives to Redux, so people mentally filed them in the same drawer. But Redux was doing two jobs at once — managing server data AND UI state — and it did both awkwardly. TanStack Query and Zustand each do one job, and they do it well.

What TanStack Query Actually Does

TanStack Query's job is to make your data layer feel like it barely exists. You call useQuery with a key and a fetch function, and you get back { data, isLoading, isError, refetch }. Behind the scenes it's maintaining a request deduplication layer, a configurable stale time (default 0ms, meaning 'always stale'), a garbage collection timer (defaults to 5 minutes), and an automatic background refresh on window focus.

The cache key system is what makes it genuinely useful. Keys are arrays, so ['todos', { status: 'active', page: 2 }] is a distinct cache entry from ['todos', { status: 'done', page: 1 }]. When you mutate a todo, you call queryClient.invalidateQueries({ queryKey: ['todos'] }) and every matching query re-fetches. No manual state updates. No synchronization bugs between your local copy and the server's truth.

It also ships with useMutation for writes, useInfiniteQuery for paginated lists, and useSuspenseQuery if you're working with React 18's Suspense boundaries. The DevTools package (@tanstack/react-query-devtools) gives you a live view of every query's state, cache entry, and fetch timestamp. If you're building anything with server data — and you almost certainly are — TanStack Query v5 should be your default, not a special case.

What Zustand Actually Does

Zustand doesn't know what a server is. It doesn't fetch anything, cache anything, or care about network state. It's a tiny (about 1.1kb gzipped) reactive store built on a subscription model. You define your state as a function, expose actions, and any component that calls useStore(state => state.count) will re-render when count changes — and only when count changes.

That's it. That's the whole API surface. Here's a real store definition:

import { create } from 'zustand'

type SidebarStore = {
  isOpen: boolean
  activeSection: string | null
  open: () => void
  close: () => void
  setSection: (section: string) => void
}

export const useSidebarStore = create<SidebarStore>((set) => ({
  isOpen: false,
  activeSection: null,
  open: () => set({ isOpen: true }),
  close: () => set({ isOpen: false }),
  setSection: (section) => set({ activeSection: section }),
}))

// In a component:
const { isOpen, open } = useSidebarStore()

That sidebar open/closed state has nothing to do with a server. It's ephemeral UI state. Zustand is exactly the right tool for it. You could persist it to localStorage with the persist middleware if you want it to survive page refreshes — but it's still local state, just serialized.

The Server State vs Client State Distinction

Here's the mental model that makes everything click: server state is owned by your backend. You have a copy of it in the browser, but that copy goes stale the moment someone else makes a change on a different tab, device, or background job. You're always working with a potentially outdated snapshot. TanStack Query is built around this reality — it assumes your data is stale and builds a whole caching and revalidation system around that assumption.

Client state is owned by the browser session. The sidebar being open or closed doesn't live anywhere except in the current tab's memory. A shopping cart count before checkout, the currently selected tab in a settings panel, whether a modal is visible — none of this needs to be 'fetched' or 'refreshed'. Zustand owns this space.

The problem in most codebases is that people mix these two categories into one global Redux store, or they try to use TanStack Query to hold UI state with odd hacks, or they use Zustand to manually cache API responses. All of those approaches work poorly. Keeping the two separate — server state in TanStack Query, client state in Zustand — is the architecture that scales without drama. If you're curious about broader framework-level tradeoffs in this space, the Next.js vs Remix comparison gets into how server/client boundary decisions affect your state management choices too.

Using Both Together (Because You Will)

In a real app you'll almost always use both. Here's a typical pattern: TanStack Query fetches your user profile, Zustand stores the current theme preference and the selected filter state for a table. They don't interfere with each other at all.

Sometimes you genuinely need to bridge them. Say TanStack Query fetches a list of products and you want to store the user's local selections for a bulk action. You can subscribe to a query result and copy it into Zustand on success — but don't do this by default. Only bridge when you have state that derives from server data but needs independent mutation without triggering a network request.

One pattern that works well: use TanStack Query's select option to derive the data you need from the cache, then pass that derived value into a Zustand action. The Query cache is still the source of truth, but your Zustand store holds the working copy the user is editing. When they save, call the mutation, and TanStack Query's invalidation brings everything back in sync. It's clean and predictable — and it pairs well with a theme toggle setup in React where your global UI preferences live in Zustand while your user settings live in the server.

Performance and Bundle Size Reality Check

TanStack Query v5 is about 13.5kb gzipped. Zustand v4 is about 1.1kb. Neither of these is a meaningful bundle cost for most apps. That said, TanStack Query's caching model has real performance implications you should understand. Queries with staleTime: Infinity never refetch automatically — useful for reference data that changes rarely, like a list of countries. Queries with staleTime: 0 refetch on every mount, which can hammer your API if you're not careful about component mounting patterns.

The gcTime (formerly cacheTime) option controls how long inactive query data stays in memory. Default is 5 minutes. For large datasets you might want to lower this. For data your users navigate back to frequently, you might want to raise it. These aren't settings you need to tune from day one, but knowing they exist prevents performance surprises later.

Zustand's performance story is simpler. Selectors control re-renders: useStore(state => state.count) only re-renders when count changes, not when unrelated state changes. If you're passing the entire store object to a component, you'll get unnecessary re-renders on every state change. Use selectors. That's the whole performance guide for Zustand.

When You Might Reach for Something Else

TanStack Query doesn't fit every data-fetching scenario. If you're doing heavy real-time work — WebSocket streams, live collaborative editing — you'll need to either integrate TanStack Query's queryClient.setQueryData with socket events or reach for something purpose-built. TanStack Query is great at polling (set refetchInterval: 3000) but it's not a WebSocket manager.

For Zustand, the main reason to look elsewhere is if you need time-travel debugging or a strict immutable update pattern. Zustand lets you mutate state in ways that make it harder to trace — the immer middleware helps, but if your team is already invested in Redux Toolkit and its slice pattern, the migration cost might not be worth it. Zustand also doesn't have built-in support for computed values (selectors that cache their result). You'll pull in zustand/middleware and handle memoization yourself.

There's also Jotai, which takes an atomic approach that fits naturally with React's component model. Worth considering if you're building something where individual atoms of state have complex interdependencies. And if you're choosing your overall framework stack from scratch, the Vite vs Next.js comparison is worth reading before you lock in your data-fetching architecture, since Next.js's server components change the calculus significantly. For full component library comparisons, the best free UI frameworks for React also covers how state management pairs with component ecosystems.

Which One Do You Need Right Now?

If your app fetches data from an API — and virtually every React app does — you need TanStack Query. Start with it on day one. The useQuery and useMutation hooks replace an enormous amount of boilerplate: manual loading states, try/catch in useEffect, manual cache invalidation, error retry logic. You'll write less code and have fewer bugs.

Do you need Zustand? Only if you have actual global client state that needs to be shared across components that aren't in a direct parent-child relationship. A lot of apps get by with plain React state (useState, useReducer, context for genuinely global things like auth status). Don't add Zustand for a problem you don't have yet.

The answer to 'which one?' is usually 'both, but for different things.' That clarity — knowing exactly what job each tool does — is what makes your codebase readable six months later when you've completely forgotten what you were thinking. Pick the boring, obvious tool for each job. That's what good frontend architecture actually looks like.

FAQ

Can I replace TanStack Query with Zustand for API data?

Technically yes, but you'd be rebuilding caching, deduplication, stale detection, background refetching, and error retry logic by hand. TanStack Query handles all of that out of the box. Using Zustand as a manual API cache is a lot of work for worse results.

Does TanStack Query v5 work with React 18 Suspense?

Yes. TanStack Query v5 ships useSuspenseQuery and useSuspenseInfiniteQuery specifically for React 18 Suspense boundaries. These throw a promise when data isn't ready, which Suspense catches and uses to show your fallback UI.

How do I persist Zustand state across page refreshes?

Use the persist middleware from zustand/middleware. You pass it a storage adapter (localStorage, sessionStorage, or a custom async adapter) and a name key. On mount, Zustand rehydrates from storage automatically. Watch out for hydration mismatches if you're server-rendering.

What's the difference between staleTime and gcTime in TanStack Query?

staleTime controls when data is considered stale and eligible for background refetching (default: 0ms). gcTime (formerly cacheTime) controls how long unused cache entries stay in memory before being garbage collected (default: 5 minutes). They're independent settings.

Do I need Redux at all if I'm using TanStack Query and Zustand?

Almost certainly not. Redux (and Redux Toolkit) make sense if you need strict unidirectional data flow, time-travel debugging via Redux DevTools, or you're inheriting a codebase already on Redux. Starting a new project? TanStack Query + Zustand covers 95% of what Redux was doing, with less boilerplate.

Can TanStack Query and Zustand share state with each other?

They don't share state natively, but you can bridge them. Use queryClient.setQueryData to write TanStack Query cache entries from anywhere, or call queryClient.getQueryData inside a Zustand action. More commonly, you'd use TanStack Query's onSuccess or select options to derive values and pass them into Zustand when needed.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

TanStack Query vs SWR vs Apollo: Data Fetching Library ChoiceZustand vs Redux Toolkit vs Jotai: State Management in 2026React State Management in 2026: Zustand vs Jotai vs ContextInfinite Query in React: TanStack Query Cursor Pagination