js

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

Learn when to use Zustand for client state and React Query for server state in React apps. Build cleaner, scalable codebases today.

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

I’ve been building React applications for a long time now, and if there’s one thing that consistently trips up developers, it’s state management. Not just any state, but the clear line between what belongs on the client and what comes from the server. I’ve seen too many projects where a user’s theme preference gets tangled up with a list of products from an API, creating a web of complexity that’s hard to debug. This friction is what led me to a powerful combination: using Zustand for the stuff that happens right in the browser, and React Query for everything that talks to a backend. Let me show you how they work together to create a cleaner, more maintainable codebase.

Think about the last app you built. How did you handle a simple switch between light and dark mode? Now, how did you manage the list of blog posts fetched from your database? These are two fundamentally different kinds of state. One is controlled entirely by the user’s interaction with the interface. The other is a snapshot of data that exists outside your app, one that can change without your frontend knowing. Mixing them is a common source of bugs.

Client state is local and instantaneous. It’s whether a modal is open, what tab is active in a sidebar, or the current value in a form before submission. It’s state that makes your UI interactive. Server state, on the other hand, is remote and asynchronous. It’s the user profile, the product inventory, or the latest notifications. This data has a life of its own on the server, and your app only has a temporary, often stale, copy of it.

This is where Zustand shines for the client side. It gives you a minimal store without the ceremony of actions, reducers, or providers. You create a store with a function, and that’s pretty much it. It feels like using React’s built-in state, but shared across components. Here’s a store for a simple UI theme:

import { create } from 'zustand';

const useThemeStore = create((set) => ({
  mode: 'light',
  toggleMode: () => set((state) => ({ 
    mode: state.mode === 'light' ? 'dark' : 'light' 
  })),
}));
// Using it in a component is straightforward.
function ThemeToggle() {
  const { mode, toggleMode } = useThemeStore();
  return <button onClick={toggleMode}>Current mode: {mode}</button>;
}

But what about that list of blog posts from an API? This is not client state. Trying to force it into a Zustand store means you now have to manually handle loading states, error handling, caching, and updating the data when it changes on the server. You end up rebuilding a lot of complex logic. Have you ever written a useEffect to fetch data and then set it in a global store, only to face issues with stale data or missing cache updates?

This is the exact problem React Query solves. It treats server state as a first-class citizen. You tell it how to fetch data, and it manages everything else: caching, background updates, retries on failure, and pagination. It knows the concept of “stale” data and can refetch it automatically when the component re-mounts or the window regains focus. Your component stays clean and focused on rendering.

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

function BlogPosts() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['posts'],
    queryFn: () => fetch('/api/posts').then(res => res.json()),
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <ul>
      {data.map(post => <li key={post.id}>{post.title}</li>)}
    </ul>
  );
}

Now, here’s the beautiful part. These two libraries don’t fight each other. They have separate jobs. Zustand manages the UI’s internal state—like our theme. React Query manages the external state—like our blog posts. You use them side by side in the same component without any special integration. The separation is mental and architectural, not technical.

In a typical component, you might use both. A sidebar filter state (client state) in Zustand can be used to create a query key for React Query (server state). This keeps concerns isolated. The filter state is quick and reactive; the query fetches new data based on that filter, handling all the async complexity.

import useFilterStore from './stores/filterStore';
import { useQuery } from '@tanstack/react-query';

function ProductList() {
  // Client state from Zustand
  const { category } = useFilterStore();
  
  // Server state from React Query, dependent on client state
  const { data: products } = useQuery({
    queryKey: ['products', category], // Query key changes with category
    queryFn: () => fetch(`/api/products?category=${category}`).then(res => res.json()),
  });

  // UI rendering using both states
  return (
    <div>
      {/* Filter UI that updates the Zustand store */}
      <select value={category} onChange={(e) => useFilterStore.setState({ category: e.target.value })}>
        <option value="all">All</option>
        <option value="electronics">Electronics</option>
      </select>
      <ul>
        {products?.map(product => <li key={product.id}>{product.name}</li>)}
      </ul>
    </div>
  );
}

Why does this pairing feel so effective in practice? It comes down to respecting boundaries. When you stop putting server data into a global client store, you eliminate whole classes of problems. You no longer have to write logic to sync your global state with the server. React Query does that for you. I remember refactoring a large dashboard that used a single massive Redux store. Moving the server data to React Query reduced the store size by 70% and made the data updates predictable and automatic.

What about more advanced scenarios? Consider a shopping cart. The items in the cart might be client state until they are submitted as an order, which then becomes server state. You can use Zustand to manage the temporary cart UI (adding/removing items, quantities) and then use React Query’s mutation hooks to send the final order to the server, invalidating related queries like the user’s order history.

import { create } from 'zustand';
import { useMutation, useQueryClient } from '@tanstack/react-query';

// Zustand store for cart (client state)
const useCartStore = create((set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
  clearCart: () => set({ items: [] }),
}));

function CheckoutButton() {
  const { items, clearCart } = useCartStore();
  const queryClient = useQueryClient();
  
  // React Query mutation for server state
  const orderMutation = useMutation({
    mutationFn: (orderData) => fetch('/api/orders', { 
      method: 'POST', 
      body: JSON.stringify(orderData) 
    }).then(res => res.json()),
    onSuccess: () => {
      // Invalidate queries to refetch order history
      queryClient.invalidateQueries({ queryKey: ['orders'] });
      clearCart(); // Clear client state after successful server update
      alert('Order placed successfully!');
    },
  });

  const handleCheckout = () => {
    orderMutation.mutate({ items });
  };

  return (
    <button onClick={handleCheckout} disabled={items.length === 0}>
      Checkout
    </button>
  );
}

Does this mean you should rip out all your existing state management? Not necessarily. But for new features or refactors, this pattern offers a clear path. It encourages you to ask a simple question for every piece of state: “Does this originate from the server?” If yes, React Query is likely the best home. If no, Zustand provides a simple and fast way to manage it.

The result is an application that scales gracefully. As your app grows, you won’t find yourself lost in a sprawling global state tree. Server state is handled with robust caching and synchronization, and client state remains lightweight and close to the components that need it. This combination has become my default choice for mid-sized to large React projects because it reduces boilerplate while increasing reliability.

I encourage you to try this approach in your next project. Start by identifying one server-fed data list and move it to React Query. Keep your UI state in Zustand. Feel the difference in clarity. If you’ve struggled with managing async data or bloated global stores, this might be the shift you need. What part of your current app’s state could benefit from this separation?

To wrap up, the synergy between Zustand and React Query comes from their focused design. Each does one job exceptionally well, and together they cover the full spectrum of state in a modern React application. By adopting this pattern, you can write code that is easier to reason about, test, and maintain. It’s a practical step towards more robust and developer-friendly applications.

If this perspective on state management resonates with you, or if you have your own experiences to share, I’d love to hear from you. Drop a comment below with your thoughts or questions. If you found this useful, please like and share it with your team or network. Let’s build better React apps 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, React state management, client state, server state



Similar Posts
Blog Image
How tRPC and Next.js Eliminate API Type Mismatches with End-to-End Safety

Discover how tRPC brings full-stack type safety to Next.js apps, eliminating API bugs and boosting developer confidence.

Blog Image
How to Integrate Prisma with Next.js: Complete Guide for Type-Safe Full-Stack Development

Learn how to integrate Prisma with Next.js for type-safe full-stack development. Build modern TypeScript apps with seamless database connectivity and enhanced DX.

Blog Image
Build High-Performance GraphQL API: NestJS, Prisma, Redis Caching Complete Tutorial

Learn to build a high-performance GraphQL API with NestJS, Prisma ORM, and Redis caching. Master DataLoader patterns, real-time subscriptions, and security optimization techniques.

Blog Image
Production-Ready Rate Limiting with Redis and Node.js: Complete Implementation Guide for Distributed Systems

Master production-ready rate limiting with Redis and Node.js. Learn Token Bucket, Sliding Window algorithms, Express middleware, and monitoring. Complete guide included.

Blog Image
GraphQL Federation with Apollo Server & TypeScript: Complete Microservices Development Guide

Learn to build a complete GraphQL Federation gateway with Apollo Server & TypeScript. Master microservices architecture, cross-service relationships, and production deployment. Start building today!

Blog Image
Build Production-Ready Rate Limiting with Redis and Node.js: Complete TypeScript Implementation Guide

Learn to build production-ready rate limiting with Redis & Node.js. Master token bucket, sliding window algorithms, Express middleware & TypeScript implementation.