js

Zustand vs React Query: The Cleanest Way to Separate Client and Server State

Learn when to use Zustand vs React Query to separate client and server state in React apps, reduce bugs, and build scalable architecture.

Zustand vs React Query: The Cleanest Way to Separate Client and Server State

I’ve spent the last few years building React applications that grow from a simple dashboard into something that feels like a living organism. Every new feature adds another state variable, another API call, another edge case. And every time I opened a component file, I found myself asking: where does this data live? Is it fetched from the server? Is it a local toggle? Should it be shared across the app?

That question became the background noise of my development life. So I started looking for a clean separation. That’s when I discovered that Zustand and React Query aren’t competing tools—they’re two halves of a whole. One manages what belongs to the client, the other owns what comes from the server. When you draw that line clearly, your codebase stops fighting itself.

Let me walk you through how I set this up, why it works, and where the hard lessons taught me to keep things simple.


Most React state management advice starts with “just use Context.” And for a todo app, that’s fine. But when your app has real-time notifications, user preferences, a shopping cart, and a dozen API endpoints, Context becomes a tangled web of re-renders. I’ve been there. You nest providers inside providers, and suddenly every page re-renders because a dropdown state changed somewhere deep in the tree.

Zustand solves client state without the nesting. It’s a tiny library—about 1 KB—that gives you a plain JavaScript store. You define your store with a function, call create, and get back a hook. No providers, no reducers, no boilerplate.

Here’s a typical client-side store I use for theme and sidebar preferences:

import { create } from 'zustand'

const useClientStore = create((set) => ({
  sidebarOpen: true,
  theme: 'light',
  toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
  setTheme: (theme) => set(() => ({ theme }))
}))

I call useClientStore((state) => state.sidebarOpen) in any component that needs it. No subscriptions to the entire store. No extra renders. It’s just state, as simple as it should be.

But now, what about the list of products I fetch from my API? That’s server state—data that lives on the backend, changes based on user actions, and needs to stay in sync. Putting that into a Zustand store would mean manually handling loading, error, caching, and invalidation. That’s where React Query takes over.

React Query treats server state like a temporary cache, not a permanent store. You define a query key, a fetch function, and the library decides when to refetch, how long to keep the data, and what happens when a mutation invalidates the cache. Here’s a standard query for products:

import { useQuery } from '@tanstack/react-query'

const fetchProducts = async () => {
  const res = await fetch('/api/products')
  if (!res.ok) throw new Error('Failed to fetch products')
  return res.json()
}

// In a component
const { data, isLoading, error } = useQuery({
  queryKey: ['products'],
  queryFn: fetchProducts,
  staleTime: 5 * 60 * 1000
})

That staleTime means the data is considered fresh for five minutes. During that window, any component that calls useQuery(['products']) gets the cached data instantly. No extra network request. No manual state management.

Now here’s where the magic happens: you combine them. The Zustand store keeps client-only data—like the currently selected product ID from a dropdown. React Query fetches the product details based on that ID.

I remember building a product detail page where users could switch between items using a quick-select menu. Early on, I stored the selected product’s full details in Zustand. Every time a user changed a selection, I had to manually fetch new data and update the store. Then the fetch could fail. Then I had to handle stale data when the user navigated away and came back. It was a mess.

The cleaner version looks like this:

// Zustand: only the selected ID
const useSelectionStore = create((set) => ({
  selectedProductId: null,
  setSelectedProductId: (id) => set({ selectedProductId: id })
}))

// In the product detail component
function ProductDetail() {
  const selectedProductId = useSelectionStore((state) => state.selectedProductId)

  const { data, isFetching } = useQuery({
    queryKey: ['product', selectedProductId],
    queryFn: () => fetch(`/api/products/${selectedProductId}`).then(r => r.json()),
    enabled: !!selectedProductId
  })

  if (!selectedProductId) return <p>Select a product</p>
  if (isFetching) return <p>Loading…</p>
  return <div>{data.name}</div>
}

Notice the enabled option—React Query won’t fetch until there’s a valid ID. The store handles only the UI concern: what the user has selected. The query handles everything about the network.


A common mistake I’ve seen (and made) is trying to mirror server data in Zustand. Someone thinks: “I’ll fetch the products, then store them in a Zustand slice so I can access them globally.” Don’t. React Query already gives you global access via the same query key. If you duplicate the data, you create two sources of truth. Updates become inconsistent. You waste time synchronizing.

Instead, let React Query be the single source of truth for any data that comes from an API. Zustand owns only what lives on the client. For example:

  • User preferences (theme, language, sidebar state)
  • Form drafts that shouldn’t be auto-saved
  • UI state (which modal is open, which tab is active)
  • Authentication tokens (though even that can be debated)

What about optimistic updates? To give instant feedback when a user submits a form, you can combine the two libraries. Use React Query’s useMutation with an onMutate callback that updates a Zustand store with the optimistic value, then roll back on error.

const mutation = useMutation({
  mutationFn: (newTodo) => fetch('/api/todos', { method: 'POST', body: JSON.stringify(newTodo) }),
  onMutate: async (newTodo) => {
    await queryClient.cancelQueries(['todos'])
    const previousTodos = queryClient.getQueryData(['todos'])
    queryClient.setQueryData(['todos'], (old) => [...old, newTodo])
    return { previousTodos }
  },
  onError: (err, newTodo, context) => {
    queryClient.setQueryData(['todos'], context.previousTodos)
    useTodoStore.getState().setOptimisticStatus(newTodo.id, 'error')
  }
})

Here, the Zustand store tracks the temporary status of the optimistic todo (pending, error). React Query manages the actual cache update and rollback. Clean separation.


But what about data that is both client and server? For instance, a user’s shopping cart. You might want to keep it on the server for persistence and share it across devices, but you also need instant responsiveness on the client. The answer: let React Query be the source of truth, and use Zustand for local overrides like a temporary discount code that hasn’t been validated yet. When the server responds, React Query invalidates the cart query, and the UI refreshes with the authoritative data.

I once built a checkout flow where the selected shipping method was stored in Zustand. The server needed to calculate tax and delivery time based on that selection. Instead of asking the server for every change, I stored the selection locally, and only sent it to the server when the user pressed “Continue.” That kept the UI snappy while allowing the server to validate on the final step.


Performance is another win. React Query deduplicates requests automatically. If two components request the same query key, only one network call is made. Zustand components only re-render when the slice of state they subscribe to changes. Combine the two, and you get fine-grained reactivity without the overhead of a monolithic store.

I measure these things because I hate waiting for pages to respond. After switching to this architecture, the time-to-interactive on my product listing page dropped by nearly 40%. The culprit had been storing fetched data in Context. Every time a filter changed, the entire provider tree re-rendered. Now, only the list component re-renders, and the data comes from the cache instantly.


Let’s talk about testing. Zustand stores are plain functions, so you can test them without a React environment. React Query allows you to mock the QueryClient and test queries in isolation. When they’re separate, your tests stay focused. I once spent a day debugging a test that failed because a state update was racing with a query fetch. After decoupling, the test became trivial: mock the fetch, assert the query data, and separately assert the client state.


The hardest lesson I learned was that everything wants to be global if you let it. When I first started, I put every bit of shared data into Zustand—user info, product lists, even the current timezone. Then I noticed that every time a user logged out, I had to manually clear ten different slices. React Query’s queryClient.clear() handled all the server cache in one line. The client state reset to defaults via a simple useClientStore.setState(initialState). That symmetry is beautiful.

So here’s my rule today: If the data exists on a server somewhere, React Query owns it. If the data only exists because of the user’s interaction with this specific browser tab, Zustand owns it. That boundary is the single most important decision you can make for long-term maintainability.


I still find myself occasionally crossing the line. A new feature arrives, and I rush to shove a flag into Zustand that actually comes from an API endpoint. But then I catch myself: Am I about to duplicate server state? If yes, I delete the unnecessary store and use a query instead. The code gets shorter. The bugs disappear.

Now I’m curious: Where in your current app are you holding server data in a client store that could be replaced by a simple query? Take a look. I bet you’ll find at least one.


If this approach saved you a few hours of debugging or gave you a cleaner architecture, hit the like button. Share this with a teammate who still passes server props through five levels of components. Drop a comment with your own experience combining state libraries—I read every one. And subscribe if you want more real-world React patterns that actually scale without the hype.

Keep it simple. Keep it separate. Build something you won’t hate in six months.


As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!


📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!


Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Keywords: Zustand, React Query, client state, server state, React architecture



Similar Posts
Blog Image
Building Distributed Rate Limiting with Redis and Node.js: Complete Implementation Guide

Learn to build scalable distributed rate limiting with Redis & Node.js. Master token bucket, sliding window algorithms, TypeScript middleware & production optimization.

Blog Image
Complete Guide to Next.js and Prisma Integration: Build Type-Safe Database-Driven Applications

Learn to integrate Next.js with Prisma ORM for type-safe, database-driven web apps. Build modern full-stack applications with seamless data management.

Blog Image
How Combining Nx and Turborepo Can Supercharge Your Monorepo Workflow

Discover how using Nx for structure and Turborepo for speed creates a scalable, high-performance monorepo setup.

Blog Image
Build Type-Safe GraphQL APIs: Complete Guide with Apollo Server, Prisma & Automatic Code Generation

Build type-safe GraphQL APIs with Apollo Server, Prisma & TypeScript. Complete tutorial covering authentication, real-time subscriptions & code generation.

Blog Image
Distributed Rate Limiting with Redis and Node.js: Complete Implementation Guide

Learn to build distributed rate limiting with Redis and Node.js. Complete guide covering token bucket, sliding window algorithms, Express middleware, and production monitoring techniques.

Blog Image
Complete Guide to Next.js Prisma Integration: Build Type-Safe Full-Stack Applications in 2024

Learn how to integrate Next.js with Prisma ORM for type-safe database operations and seamless full-stack development. Build modern web apps efficiently.