js

Zustand vs React Query: The Smart Way to Split Client and Server State

Learn when to use Zustand for client state and React Query for server state to build faster, cleaner, scalable React apps.

Zustand vs React Query: The Smart Way to Split Client and Server State

I still remember the day I stared at a single Redux store that had become a thousand-line monster. It held user session data, the current weather, a shopping cart, a list of unread notifications, and the selected theme color. Every time a component re-rendered, it triggered a chain of selectors, middlewares, and reducers that no one on the team fully understood. That was the moment I realized I was fighting the wrong battle. The real problem wasn’t state management — it was mixing two completely different kinds of data inside the same container.

Let me ask you something: have you ever tried to cache an API response inside your global state manager? If you have, you know the pain of manually invalidating data, handling retries, and keeping loading flags in sync across three dozen components. It’s a recipe for subtle bugs and endless meetings. That’s why I turned to a cleaner approach: using Zustand for client state and React Query for server state.

Why would you want to split your state into two separate tools? The answer is simple. Client state is anything that lives only on the client side — UI theme, modal open/close, form inputs, sidebar collapse state. Server state is data that comes from an API — user profiles, product catalogs, order history. These two types have completely different lifecycles and needs. Server state changes based on what’s on the backend, requires caching, refetching, and deduplication. Client state changes based on user interaction and doesn’t need to be persisted to any server. They should not live in the same box.

Zustand is a tiny, fast, and scalable state management solution that gives you a store without boilerplate. You define a store, you read and write state with hooks. That’s it. No action creators, no reducers, no switch statements. I’ve used it for things like a multi-step wizard where each step tracks its own input values and validation errors. Here’s a small example:

import { create } from 'zustand';

const useWizardStore = create((set) => ({
  currentStep: 1,
  formData: {},
  setStep: (step) => set({ currentStep: step }),
  updateForm: (data) => set((state) => ({ formData: { ...state.formData, ...data } })),
}));

That’s it. Now any component can call useWizardStore((state) => state.currentStep) and get the current step. No overhead. If you need to reset the wizard, you call useWizardStore.getState().setStep(1). Clean and predictable.

React Query, on the other hand, handles everything related to fetching, caching, and synchronizing server state. It gives you useQuery and useMutation hooks. You tell it a unique key and a function that fetches data, and it takes care of the rest — caching, background refetching, error retries, stale time, and more. No global store. No manual cache invalidation. For example, to fetch a user profile:

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

function UserProfile({ userId }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
    staleTime: 5 * 60 * 1000, // keep data fresh for 5 minutes
  });

  if (isLoading) return <Spinner />;
  if (error) return <Error message={error.message} />;
  return <ProfileCard user={data} />;
}

No need to write a separate state object for loading, error, or data. React Query gives you all three out of the box. It also deduplicates requests — if two components call the same query key at the same time, only one fetch happens. That’s a huge performance win.

The real beauty comes when you combine them. Imagine a product page where you have a list of items fetched from the server (React Query) and a set of user‑selected filters that are purely client state (Zustand). The filters determine what you pass to your query. Here’s how that works:

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

const useFilterStore = create((set) => ({
  category: 'all',
  priceRange: [0, 1000],
  setCategory: (cat) => set({ category: cat }),
  setPriceRange: (range) => set({ priceRange: range }),
}));

function ProductList() {
  const { category, priceRange } = useFilterStore();
  const { data, isLoading } = useQuery({
    queryKey: ['products', { category, priceRange }],
    queryFn: () => fetch(`/api/products?cat=${category}&min=${priceRange[0]}&max=${priceRange[1]}`)
      .then(res => res.json()),
  });

  if (isLoading) return <Loading />;
  return <ProductGrid products={data} />;
}

When the user changes the category, Zustand updates, the query key changes, and React Query automatically refetches. The cache for the old key remains, so if the user goes back, the data loads instantly. No side effects, no extra dispatches.

I’ve seen teams try to put server data into a Zustand store and then manually handle caching and invalidation. It works, but it’s like using a Swiss Army knife to carve a turkey — you can do it, but a proper carving knife (React Query) makes it easier and cleaner. The two libraries are best friends. Zustand deals with the transient, UI‑specific state. React Query deals with the persistent, server‑backed state.

What about mutations? When you update something on the server, you want the client state to reflect that change. React Query’s useMutation can invalidate related queries on success. You can also update the Zustand store after a mutation if needed. For example, after adding an item to a cart (server mutation), you may want to update a local cartCount in Zustand to show a badge immediately, without waiting for a refetch. That’s a clean pattern:

import { useMutation, useQueryClient } from '@tanstack/react-query';
import useCartStore from './cartStore';

function AddToCartButton({ productId }) {
  const queryClient = useQueryClient();
  const incrementCount = useCartStore((state) => state.incrementCount);

  const mutation = useMutation({
    mutationFn: () => fetch('/api/cart/add', { method: 'POST', body: JSON.stringify({ productId }) }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['cart'] });
      incrementCount(); // optimistic client update
    },
  });

  return <button onClick={() => mutation.mutate()}>Add to Cart</button>;
}

Now the server state (the cart contents) is refreshed, and the client state (the cart counter) is updated immediately. The user sees a seamless experience.

I learned this pattern the hard way after building a dashboard that had to display real‑time metrics while allowing users to resize panels and choose chart types. The metrics were fetched from a stream (React Query), the panel layout and chart preferences were client state (Zustand). Trying to merge them into a single store would have been chaos. Separation kept the code readable and testable.

Have you ever tried to test a component that relies on both server and client state? With this approach, you can mock React Query to return data and directly set Zustand state in your tests. No mocking a huge store. You test each part independently.

Now, I’m not saying every app needs both libraries. If your app has no server data, just use Zustand. If your app is purely data‑driven with little client‑side UI logic, just use React Query. But for most real‑world applications — dashboards, e‑commerce, social feeds, admin panels — this pair is ideal.

One more thing: performance. Zustand’s store uses a subscription model that re‑renders only components that subscribe to changed slices. React Query uses structural sharing to avoid unnecessary re‑renders. Together, they minimise the number of updates your app triggers. That’s a big deal when you have hundreds of components.

So, the next time you start a React project, consider letting go of the idea that one state manager must rule them all. Think about the nature of your data. Give server data to React Query. Give client data to Zustand. Keep them separate, but let them talk when needed.

If you found this article useful, I’d love to hear your own experiences with state separation. Hit the like button if it helped you see things differently, share it with a teammate who might be wrestling with a bloated store, and leave a comment below with your biggest state management headache. I read every reply. Now go build something that scales.


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 state management



Similar Posts
Blog Image
Tracing Distributed Systems with OpenTelemetry: A Practical Guide for Node.js Developers

Learn how to trace requests across microservices using OpenTelemetry in Node.js for better debugging and performance insights.

Blog Image
Build Distributed Task Queue System with BullMQ, Redis, and TypeScript - Complete Guide

Learn to build scalable distributed task queues with BullMQ, Redis, and TypeScript. Master job processing, retries, monitoring, and multi-server scaling with hands-on examples.

Blog Image
Build High-Performance File Upload System: Multer, Sharp, AWS S3 in Node.js

Build a high-performance Node.js file upload system with Multer, Sharp & AWS S3. Learn secure uploads, image processing, and scalable storage solutions.

Blog Image
Build High-Performance REST APIs with Fastify, Prisma, and Redis: Complete Production Guide

Learn to build lightning-fast REST APIs with Fastify, Prisma ORM, and Redis caching. Complete guide with authentication, validation, and performance optimization.

Blog Image
Complete Guide to Integrating Next.js with Prisma ORM for Type-Safe Database Operations

Learn how to integrate Next.js with Prisma ORM for type-safe, scalable web apps. Get step-by-step setup, best practices, and real-world examples.

Blog Image
Build Type-Safe GraphQL APIs with TypeGraphQL and TypeORM in Node.js

Eliminate duplicate types and boost productivity by combining TypeGraphQL with TypeORM for a fully type-safe GraphQL API.