js

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

Learn when to use Zustand for client state and React Query for server state to improve caching, performance, and maintainable React apps.

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

Have you ever found yourself duplicating API data inside a global Redux store just to keep your UI consistent? I certainly have, and it was a nightmare. The data would go stale, I’d have to write custom invalidation logic, and every time someone refreshed the page the whole thing fell apart. That experience taught me one painful lesson: client state and server state are two completely different animals, and treating them the same is a recipe for technical debt.

I started thinking about this topic again after reviewing a project where the team was using Zustand for everything — including caching server responses. They had manually implemented background refetching with setInterval, which meant the app was constantly polling APIs even when the user wasn’t looking. The code was brittle, the performance was terrible, and everyone was confused about where data lived. Clearly, we needed a better way.

The answer came in the form of two complementary libraries: Zustand for client-side, ephemeral state, and React Query (TanStack Query) for server-side, asynchronous state. When you combine them, you get a clean separation of concerns that scales beautifully.

Let me explain exactly how this works, with code you can copy.


Why not one library for everything?

Many developers reach for a single state management solution like Redux, MobX, or even Zustand alone and try to force everything through that funnel. But think about this: server state is inherently remote, asynchronous, and requires caching, refetching, and synchronization. Why would you want to write code to replicate that logic when a library like React Query already does it perfectly?

Client state, on the other hand, is synchronous, lives in the browser, and disappears when the user closes the tab. A UI toggle, a selected tab, a dark mode preference — these don’t need to talk to a server. They just need to be fast and simple. That’s where Zustand shines.

By splitting responsibilities, you avoid the worst anti-pattern I see: storing server responses inside a client store and then manually updating them on user actions. You also avoid bloating your Zustand store with arrays of posts, user profiles, or product listings. Your store stays lean, your queries stay cached, and your mental model stays clear.


Setting up Zustand for client state

Let’s start with the easy part. I’ll create a tiny Zustand store for some UI state that lives only on the client. Imagine a settings panel where the user can toggle a sidebar, change a theme, and store a search query — all ephemeral, nothing to save on the server.

import { create } from 'zustand';

const useUIStore = create((set) => ({
  sidebarOpen: false,
  theme: 'light',
  searchQuery: '',
  toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
  setTheme: (theme) => set({ theme }),
  setSearchQuery: (query) => set({ searchQuery: query }),
}));

export default useUIStore;

That’s it. No providers, no context, no boilerplate. I can use this anywhere in my app:

import useUIStore from './stores/uiStore';

function Sidebar() {
  const sidebarOpen = useUIStore((state) => state.sidebarOpen);
  const toggleSidebar = useUIStore((state) => state.toggleSidebar);

  return (
    <div>
      <button onClick={toggleSidebar}>Toggle</button>
      {sidebarOpen && <aside>Some content</aside>}
    </div>
  );
}

Notice I’m using a selector (state => state.sidebarOpen) so the component only re-renders when that specific piece of state changes. This is performant, simple, and easy to test.


Setting up React Query for server state

Now for the server side. I’ll use React Query to fetch a list of users from an API. I don’t want to store this list in Zustand — that would force me to manage loading, error, caching, and refetching manually. Instead, I let React Query handle everything.

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

async function fetchUsers() {
  const response = await fetch('https://jsonplaceholder.typicode.com/users');
  if (!response.ok) throw new Error('Failed to fetch');
  return response.json();
}

export function useUsers() {
  return useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
    staleTime: 5 * 60 * 1000, // 5 minutes
    refetchOnWindowFocus: true,
  });
}

Now any component that needs the user list simply calls useUsers():

function UserList() {
  const { data: users, isLoading, isError } = useUsers();

  if (isLoading) return <p>Loading…</p>;
  if (isError) return <p>Something went wrong.</p>;

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

No manual useEffect, no setState, no cleaning up intervals. React Query automatically re-fetches when the query key changes, invalidates stale data, and caches results. This is the right tool for server data.


How they play together

The real magic happens when you combine them. For example, imagine a dashboard where the user can toggle a filter (client state) that affects which data to request (server state). Zustand holds the filter value, and React Query uses it in the query key.

const useFilterStore = create((set) => ({
  status: 'all',
  setStatus: (status) => set({ status }),
}));

function useFilteredUsers() {
  const status = useFilterStore((state) => state.status);
  return useQuery({
    queryKey: ['users', { status }],
    queryFn: () => fetchUsersByStatus(status),
  });
}

When the user changes the filter via Zustand’s setStatus, the query key changes, and React Query automatically fetches the new data. No custom trigger, no manual coordination. The two libraries communicate through a simple data dependency.

Another example: say the user adds a new user via a form. After a successful mutation with React Query’s useMutation, I can invalidate the ['users'] query to refresh the list. Meanwhile, I may also want to close a modal (client state) after submission. That’s a simple call to the Zustand store.

import { useMutation, useQueryClient } from '@tanstack/react-query';
import useUIStore from './stores/uiStore';

function UserForm() {
  const queryClient = useQueryClient();
  const closeModal = useUIStore((state) => state.closeModal);

  const mutation = useMutation({
    mutationFn: createUser,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
      closeModal();
    },
  });

  // ... form JSX
}

Clean separation. The mutation logic handles server state (invalidation), the UI store handles local state (modal close). Each piece stays in its own lane.


Personal lessons learned

I once inherited a project where the previous developer had put all API responses inside a Zustand store. The store had hundreds of keys, actions that fetched data and stored it, and manual refetch intervals. It was a tangled mess. When a bug appeared — say, a user’s name not updating after an edit — we had to trace through three layers of custom caching logic.

Integrating Zustand with React Query transformed that codebase. We removed about 60% of the Zustand store’s keys (all server data) and replaced them with React Query hooks. The remaining Zustand actions — theme toggles, sidebar visibility, unsaved form data — became trivial to manage. Debugging became a matter of checking the React Query devtools instead of reading spaghetti.

If you’re building a new React app today, I urge you to make this split from day one. It will save you weeks of refactoring later.


Quick checklist for your next project

  1. Client state – use Zustand for UI toggles, form drafts, local preferences, modals, sidebar state. Keep it lightweight.
  2. Server state – use React Query for all API data, mutations, caching, background refetching.
  3. Edge cases – when you need to merge local and server data (e.g., optimistic updates), use React Query’s mutation callbacks to update the cache, and use Zustand only for the temporary local snapshot during the mutation.
  4. Testing – you can test Zustand stores in isolation. For React Query, you can mock the hook or use a test provider.

A final thought

What’s the one line that separates client state from server state in your current app? If you can’t answer that quickly, you probably have an architectural problem. The Zustand + React Query combo forces you to define that boundary explicitly. It’s not just about technical elegance — it’s about building software that your future self (or your teammates) can understand without a full archeology dig.

I’ve seen this pattern save teams from months of pain. It’s simple, it’s scalable, and it respects the fundamental difference between what lives in your browser and what lives on your server.

If you enjoyed this article and found it helpful, please like, share, and comment below. What’s your go-to approach for separating client state and server state? I’d love to hear your experiences — or your war stories. Drop a comment and let’s learn together.


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, TanStack Query, client state, server state



Similar Posts
Blog Image
Build Event-Driven Microservices with Node.js, TypeScript, and Apache Kafka: Complete Professional Guide

Learn to build scalable event-driven microservices with Node.js, TypeScript & Apache Kafka. Master distributed systems, CQRS, Saga patterns & deployment strategies.

Blog Image
Type-Safe NestJS Microservices with Prisma and RabbitMQ: Complete Inter-Service Communication Tutorial

Learn to build type-safe microservices with NestJS, Prisma, and RabbitMQ. Complete guide to inter-service communication, error handling, and production deployment.

Blog Image
Build High-Performance GraphQL APIs with NestJS, Prisma, and Redis Caching

Learn to build high-performance GraphQL APIs with NestJS, Prisma ORM, and Redis caching. Master resolvers, DataLoader optimization, real-time subscriptions, and production deployment strategies.

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 full-stack apps. Build seamless database operations with auto-generated schemas and TypeScript support.

Blog Image
How to Build Secure, Scalable APIs with AdonisJS and Node.js

Learn how to create fast, secure, and production-ready APIs using AdonisJS with built-in authentication, validation, and database tools.

Blog Image
Next.js Prisma Integration Guide: Build Type-Safe Full-Stack Apps with Modern Database Management

Learn how to integrate Next.js with Prisma for powerful full-stack development. Build type-safe, scalable web apps with seamless database operations in one codebase.