js

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

Learn how Zustand and React Query simplify React state management by separating client and server state for cleaner, faster apps.

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

I still remember the moment I realized I had been doing state management wrong for years. It was 2 AM, staring at a dashboard where user preferences kept overriding fetched data, and cached responses were conflicting with local edits. I had everything in Redux, and it was a nightmare. That night, I decided to split my state into two distinct categories: client state and server state. That decision led me to combine Zustand and React Query, and I have never looked back.

Here is what I learned: server state comes from an API and changes without your control. Client state exists only in the browser and changes based on user actions. When you force both into one tool, you end up manually syncing caches, writing reducers for loading flags, and fighting stale data. React Query handles server state automatically — it fetches, caches, refetches on window focus, and invalidates queries when you mutate data. Zustand handles client state — toggles, form inputs, selected items, UI flags. They are two different jobs, and they need two different tools.

But how do you make them talk to each other without creating chaos? The answer is simpler than you might think.

First, let me show you a typical Zustand store for client state. I use it for things like sidebar visibility and selected filters. Here is a minimal example:

import { create } from 'zustand';

const useUIStore = create((set) => ({
  sidebarOpen: true,
  selectedFilter: 'all',
  toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
  setFilter: (filter) => set({ selectedFilter: filter }),
}));

Now, for server state, I use React Query to fetch a list of products. Notice how there is no loading state in Zustand — React Query provides isLoading, error, and data automatically.

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

function useProducts() {
  return useQuery({
    queryKey: ['products'],
    queryFn: () => fetch('/api/products').then((res) => res.json()),
  });
}

The real magic happens when you need to combine them. Suppose you want to show only products that match the selected filter from your client state. You can read the filter from Zustand inside your component, then pass it to React Query. But what if you need to filter on the server side? That is where you combine them elegantly.

Here is a pattern I use often. I store the filter value in Zustand, then read it inside the React Query hook to trigger a new server request whenever the filter changes.

function useFilteredProducts() {
  const selectedFilter = useUIStore((state) => state.selectedFilter);

  return useQuery({
    queryKey: ['products', selectedFilter],
    queryFn: () => fetch(`/api/products?category=${selectedFilter}`).then((res) => res.json()),
  });
}

This works because React Query uses the query key to cache and refetch. When selectedFilter changes, React Query sees a new key and automatically fetches fresh data. Zustand handles the filter selection, React Query handles the fetching and caching. No manual dispatch, no middleware, no extra boilerplate.

Have you ever tried to keep a local edit state in sync with a server response? That is where the separation really shines. Let me give you an example from a project I worked on recently. I had a form where users could edit their profile name. The profile data came from the server via React Query. But I did not want to send a PUT request on every keystroke. Instead, I stored the local draft in Zustand, and only when the user clicked “Save” did I use a React Query mutation.

const useProfileDraft = create((set) => ({
  name: '',
  setName: (name) => set({ name }),
  reset: () => set({ name: '' }),
}));

function ProfileForm() {
  const { data: profile } = useQuery({ queryKey: ['profile'], queryFn: fetchProfile });
  const { name, setName, reset } = useProfileDraft();
  const mutation = useMutation({ mutationFn: updateProfile });

  useEffect(() => {
    if (profile) setName(profile.name);
  }, [profile, setName]);

  const handleSave = async () => {
    await mutation.mutateAsync({ name });
    reset();
  };

  return (
    <div>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <button onClick={handleSave} disabled={mutation.isLoading}>Save</button>
    </div>
  );
}

Do you see how clean that is? The server data initializes the client draft, but the draft stays independent until you commit it. No double fetching, no stale data inside the input field. React Query handles the mutation and invalidates the profile query on success. Zustand keeps the UI responsive even while the request is in flight.

I find this especially useful for things like shopping carts. The cart should show local changes instantly (client state via Zustand), but the final order should sync to the server (React Query mutation). If you try to do that with only one state manager, you end up writing custom optimistic update logic. Here, it is just two separate concerns.

Another common case: pagination. You store the current page number in Zustand, and use it as a query parameter in React Query. When the user clicks “next page”, Zustand updates the page, which triggers a new React Query fetch. The loading state comes from React Query, and you can display a spinner while the data loads.

const usePagination = create((set) => ({
  page: 1,
  nextPage: () => set((state) => ({ page: state.page + 1 })),
  prevPage: () => set((state) => ({ page: Math.max(1, state.page - 1) })),
}));

function usePageData() {
  const page = usePagination((state) => state.page);
  return useQuery({
    queryKey: ['data', page],
    queryFn: () => fetch(`/api/data?page=${page}`).then((res) => res.json()),
  });
}

I have used this pattern in production for months. The biggest benefit is debugging. When something goes wrong, I know exactly where to look. If the data is wrong, it is either a server issue or a React Query cache invalidation problem. If the UI is behaving oddly, it is likely a Zustand store issue. They do not tangle.

If you are worried about performance, do not be. Zustand selects only the slices you need, and React Query memoizes query results. The combination is actually more efficient than a single global store that re-renders everything on any change.

Let me leave you with one piece of advice: start small. Pick one feature in your current app — maybe a search bar with results — and separate the search query (client) from the search results (server). You will feel the difference immediately.


If you found this helpful, please like this article, share it with a teammate who still uses Redux for everything, and comment below with your own experience combining Zustand and React Query. I read every reply and would love to hear how you handle client and server state in your projects.


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
Build a Distributed Task Queue System with BullMQ, Redis, and TypeScript: Complete Professional Guide

Learn to build a distributed task queue system with BullMQ, Redis & TypeScript. Complete guide with worker processes, monitoring, scaling & deployment strategies.

Blog Image
How Next.js 14 Server Actions Simplified My Full-Stack Forms

Discover how Server Actions in Next.js 14 eliminate boilerplate, improve validation, and streamline full-stack form development.

Blog Image
Complete Guide to Building Full-Stack TypeScript Apps with Next.js and Prisma Integration

Learn how to integrate Next.js with Prisma for powerful full-stack TypeScript applications. Get end-to-end type safety, seamless data flow, and enhanced developer experience.

Blog Image
How to Build End-to-End Encrypted Chat with Libsodium and the Double Ratchet

Learn how to build end-to-end encrypted chat with Libsodium, X3DH, and Double Ratchet for true message privacy and forward secrecy.

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, full-stack web apps. Build database-driven applications with seamless frontend-backend development.

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

Learn how to integrate Next.js with Prisma ORM for type-safe full-stack development. Build scalable web apps with robust database management and SSR.