TanStack Query + Zustand: Patterns and Anti-Patterns for Separating Server State and Client State
Looking back at the days of using Redux, I remember API responses and modal open/close states all jumbled together in a single store. There were plenty of days spent wrestling with a debugger asking, "Where did this even change?" Then when I adopted TanStack Query (formerly React Query), something felt different — but at first I only understood it as "caching got easier." It took a long time to understand why it was fundamentally different.
This article walks through code examples covering what state each library should own, how to design the boundary between them, and how to avoid the traps that commonly appear in practice. If you're a frontend developer working on React-based projects, this is something you can apply right away.
Server state and client state should be handled by different libraries from the start, and the core of this article is why the TanStack Query + Zustand combination divides those responsibilities most cleanly.
Core Concepts
Server State vs. Client State: Why Separate Them?
I was confused at first too, but once you understand the fundamental difference between the two, the separation feels quite natural.
| Server State | Client State | |
|---|---|---|
| Location | Remote database, API | Browser memory |
| Nature | Asynchronous, can be changed externally at any time | Synchronous, only your app controls it |
| Examples | Product listings, user profile, order history | Modal open/close, search filter values, form wizard step |
| Management tool | TanStack Query | Zustand |
Server state can change due to other users or backend batch jobs even when you're doing nothing. That means you always have to ask "Is this data current?", and you need cache expiration, background refetching, and duplicate request deduplication. Client state, on the other hand, can only be changed by your own app code, and it's transient data that's fine to lose on a page refresh.
Core principle: The moment you copy server data into Zustand, you create two sources of truth. It's only a matter of time before those two copies diverge and bugs appear.
How TanStack Query Handles Server State
TanStack Query manages its cache based on queryKey. Even if multiple components request data with the same key simultaneously, only one actual network request goes out, and once the stale time passes, it automatically refetches in the background. This strategy is called stale-while-revalidate. Even when cached data is stale, it shows that data first while simultaneously fetching fresh data in the background — from the user's perspective, they see something immediately instead of a blank screen, making for a smoother UX. Implementing all of this yourself would require fairly complex logic, and TanStack Query handles it for you.
How Zustand Handles Client State
Zustand is an ultra-lightweight library at roughly 1KB gzipped. It requires no Provider layer, and because it creates a singleton store, it's accessible from anywhere in the component tree. Unlike Redux, there's no need to separately define actions and reducers — you can spin up a store with a single create function.
import { create } from 'zustand'
interface UIState {
isModalOpen: boolean
openModal: () => void
closeModal: () => void
}
const useUIStore = create<UIState>((set) => ({
isModalOpen: false,
openModal: () => set({ isModalOpen: true }),
closeModal: () => set({ isModalOpen: false }),
}))The moment API response data enters this store, you've stepped outside the domain this library is good at.
Practical Application
Example 1: Basic Role Separation
Let's start with the most basic pattern. Here's a structure that completely separates server state (fetching a product list) from client state (managing items in a shopping cart).
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { create } from 'zustand'
// ✅ Server state — TanStack Query's responsibility
// Caching, refetching, and loading/error states are handled automatically
function useProducts() {
return useQuery({
queryKey: ['products'],
queryFn: async () => {
const res = await fetch('/api/products')
if (!res.ok) throw new Error('Failed to load product list')
return res.json()
},
staleTime: 1000 * 60 * 5, // stay fresh for 5 minutes
})
}
// ✅ Client state — Zustand's responsibility
// Pure UI state that resets on page refresh
interface CartState {
selectedItems: string[]
addItem: (id: string) => void
removeItem: (id: string) => void
}
const useCartStore = create<CartState>((set) => ({
selectedItems: [],
addItem: (id) => set((state) => ({
selectedItems: [...state.selectedItems, id],
})),
removeItem: (id) => set((state) => ({
selectedItems: state.selectedItems.filter((item) => item !== id),
})),
}))| Role | Library | Reason |
|---|---|---|
| Product list fetching | TanStack Query | Exists on server, requires cache management |
| Selected item list | Zustand | Exists only in the browser, no server sync needed |
Example 2: Search Filters — Where the Two States Meet
This is a slightly more advanced version of useProducts from before. It's a scenario where filter values entered by the user (client state) feed into server request parameters — a situation you'll frequently encounter in practice. The common mistake here is writing code to manually re-fetch data every time a filter changes. If you design your queryKey well, that's unnecessary.
// Zustand: filter values the user manipulates (client state)
interface FilterState {
keyword: string
category: string
setKeyword: (keyword: string) => void
setCategory: (category: string) => void
}
const useFilterStore = create<FilterState>((set) => ({
keyword: '',
category: 'all',
setKeyword: (keyword) => set({ keyword }),
setCategory: (category) => set({ category }),
}))
// TanStack Query: queries the server with filter values included in the queryKey
function useFilteredProducts() {
// Subscribe only to the state you need using a selector
// Subscribing to the whole store can cause re-renders on changes unrelated to keyword or category
const keyword = useFilterStore((s) => s.keyword)
const category = useFilterStore((s) => s.category)
return useQuery({
queryKey: ['products', { keyword, category }],
queryFn: () => fetchProducts({ keyword, category }),
// v5 replacement for v4's keepPreviousData — preserves previous data during filter transitions to prevent flickering
placeholderData: (previousData) => previousData,
})
}The key part of this pattern is queryKey: ['products', { keyword, category }]. When Zustand's state changes, the queryKey changes, and TanStack Query recognizes it as a new query and fetches the data automatically. There's no reason at all to store the server response back into Zustand.
Example 3: Mutations and UI Feedback
Post-mutation state handling can also be cleanly divided by role. The part that tripped me up early on in this pattern was "should toast messages go into Zustand too?" — and the answer is yes, of course. They're pure UI state, not server data. Server state updates are handled by TanStack Query's invalidateQueries, while UI feedback belongs to Zustand.
// Zustand: pure UI feedback state like toasts
const useUIStore = create<{ toastMessage: string; showToast: (msg: string) => void }>((set) => ({
toastMessage: '',
showToast: (msg) => {
set({ toastMessage: msg })
setTimeout(() => set({ toastMessage: '' }), 3000)
},
}))
// TanStack Query: handles mutations and cache invalidation
function useUpdateProfile(userId: string) {
const queryClient = useQueryClient()
const { showToast } = useUIStore()
return useMutation({
mutationFn: updateUserProfile,
onSuccess: () => {
// Update server state — invalidate Query cache to trigger automatic refetch
queryClient.invalidateQueries({ queryKey: ['user', userId] })
showToast('Profile has been updated')
},
onError: () => {
showToast('Update failed. Please try again')
},
})
}Example 4: Multi-Step Forms — The Clearest Case for Client State
The case where I most clearly felt "this separation is unambiguous" in practice was the form wizard. A multi-step form only needs to communicate with the server at the final submission. Everything before that is entirely in the client domain.
// Zustand: current step and input data for the form (client state)
interface FormStore {
step: number
// In real projects, it's recommended to type each step's data concretely using a union
// e.g.: type StepData = Step1Data | Step2Data | Step3Data
formData: Record<string, unknown>
nextStep: () => void
prevStep: () => void
setField: (key: string, value: unknown) => void
reset: () => void
}
const useFormStore = create<FormStore>((set) => ({
step: 1,
formData: {},
nextStep: () => set((s) => ({ step: s.step + 1 })),
prevStep: () => set((s) => ({ step: Math.max(1, s.step - 1) })),
setField: (key, value) =>
set((s) => ({ formData: { ...s.formData, [key]: value } })),
reset: () => set({ step: 1, formData: {} }),
}))
// TanStack Query: handles only the final submission (server communication)
function useSubmitOrder() {
const { formData, reset } = useFormStore()
return useMutation({
mutationFn: () => submitOrder(formData),
onSuccess: () => {
reset() // reset form after submission
},
})
}Pros and Cons
Advantages
| Item | Details |
|---|---|
| Separation of concerns | Each library handles only its area of expertise, making code easier to reason about |
| Simplified Zustand store | Once TanStack Query absorbs server data, only pure UI state remains in Zustand, making the store extremely lean |
| Automatic cache management | No need to implement refetching, stale time, or duplicate request deduplication yourself |
| Independent DevTools | TanStack Query DevTools and Zustand DevTools (Redux DevTools integration) can be debugged independently |
| Bundle size | TanStack Query ~13KB + Zustand ~1KB (gzip), much lighter than Redux |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Ambiguous boundary judgment | If the team doesn't clearly agree on "is this state server or client?", mixing will recur. I've personally had frequent clashes with teammates over this during PR reviews. | Document the criteria as an ADR (Architecture Decision Record) within the team |
| queryKey design overhead | If Zustand state isn't correctly reflected in the queryKey, stale data may be shown | It's recommended to include all filter- and pagination-related variables in the queryKey |
| Optimistic update complexity | When rollback is needed, you need a solid understanding of the onMutate/onError pattern |
Refer to the Optimistic Updates guide in the official TanStack Query documentation |
| Singleton management in MFE environments | The contextSharing prop was removed in TanStack Query v5, due to issues with Context API-based sharing unintentionally isolating QueryClient instances in Module Federation environments. (Not applicable if you're not using micro frontends.) |
Use the Federated State pattern where the host app exposes the QueryClient instance and remote apps import it |
The Most Common Mistakes in Practice
- The anti-pattern of saving response data to Zustand in
onSuccess: You can just usequeryClient.getQueryData()or the return value ofuseQuerydirectly. Copying it into Zustand unnecessarily means the moment those two fall out of sync, debugging hell begins. - Forgetting to include Zustand state that the query depends on in the queryKey: If you don't put filter values in the queryKey, changing the filter will still show the cached previous result. If filter changes aren't being reflected in a component, the first thing to check is the queryKey.
- The habit of deferring the server-vs-client decision: If you think "let's just put it in Zustand for now and separate it later," the dependencies will have become too tangled to separate later. Building the habit from the start of asking "Does this data exist on the server?" is what matters.
Closing Thoughts
The reason the TanStack Query + Zustand combination is powerful is that each focuses only on what it's good at, and whether you consciously design this separation from the start is what determines the complexity of your codebase.
Three steps you can take right now:
- Try writing out a list of all the state in your current project. Next to each piece of state, mark "Does this exist on the server? (Y/N)" — you'll immediately see which state has ended up in the wrong library.
- Install TanStack Query and migrate the API response data you've been managing in Zustand over to
useQuery. A single line —pnpm add @tanstack/react-query @tanstack/react-query-devtools— is all you need to get started. - Setting up DevTools on both sides lets you observe cache state in real time via TanStack Query DevTools. Zustand state can be inspected in Redux DevTools — for that, you'll need to wrap your store with the
devtoolsmiddleware fromzustand/middleware(in the formcreate(devtools(...))). Having both DevTools open side by side immediately makes it clear just how clean the boundary is.
References
- Does TanStack Query replace client state managers? | TanStack Query official docs
- Overview | TanStack Query official docs
- Federated State Done Right: Zustand, TanStack Query, and the Patterns That Actually Work | DEV Community
- Separating Concerns with Zustand and TanStack Query | volodymyrrudyi.com
- Redux vs TanStack Query & Zustand in 2025 | bugragulculer.com
- Woowacon 2023 — Frontend State Management in Practice with React Query & Zustand | velog
- Separating Server State and Client State with React Query + Zustand | velog
- When Adopting React Query, Why Must You Also Change State Management and Architecture? | Medium