js

Zustand and React Query: A Clear State Management Pattern for Scalable React Apps

Learn when to use Zustand for client state and React Query for server state to build cleaner, scalable React apps. Read the guide now.

Zustand and React Query: A Clear State Management Pattern for Scalable React Apps

I’ve been thinking a lot about state management lately. In my projects, I’ve watched simple components turn into complex webs of useState, useEffect, and context providers. The hardest part isn’t writing the code—it’s knowing where to put things. Should this piece of data live globally? Is it only relevant to this server request? This confusion is why the pairing of Zustand and React Query has become so important to me. Let’s talk about how these two tools, when used together, can bring clarity and power to your application’s architecture.

Think about the different kinds of information in your app. You have data that comes from an API, like a user’s profile or a list of products. This data belongs to the server. Then you have data that exists only in the browser, like whether a sidebar is open, the current theme, or a multi-step form’s progress. Mixing these two types of state in the same place is a common source of bugs.

This is where a clear separation saves the day. Instead of one giant tool trying to do everything, we use two specialized tools. Zustand is for client state—the things that happen purely on the user’s machine. React Query is for server state—the data we fetch, cache, and update from a backend. When you stop forcing your server cache into your UI state manager, everything gets simpler.

What does client state look like in practice? Imagine a dashboard with collapsible panels, a dark mode toggle, and a complex filter widget for a data table. This is Zustand’s territory. It gives you a minimal, straightforward way to create a store. You write a function that defines your state and actions. The result is a hook you can use anywhere in your app.

Here’s a small example. Let’s create a store for UI preferences.

import { create } from 'zustand';

const useUiStore = create((set) => ({
  isSidebarOpen: true,
  theme: 'light',
  toggleSidebar: () => set((state) => ({ isSidebarOpen: !state.isSidebarOpen })),
  setTheme: (newTheme) => set({ theme: newTheme }),
}));

// Using it in a component
function Header() {
  const { isSidebarOpen, toggleSidebar } = useUiStore();
  return <button onClick={toggleSidebar}>{isSidebarOpen ? 'Close' : 'Open'}</button>;
}

It’s just a custom hook. There’s no provider to wrap your app in, no complex boilerplate. You call useUiStore() and get exactly the state and functions you need. This simplicity is its greatest strength for managing local interactions.

Now, what about the data from your database? This is where React Query changes the game. It assumes that server data is a cache that needs careful management. Have you ever struggled with knowing when to refetch data after a user submits a form? React Query handles that for you.

You tell it how to fetch data for a specific “query key.” It handles caching, background updates, and error states. When you perform a mutation, like posting a new item, you can tell React Query to update the relevant cache. This keeps your UI in sync with the server without manual refetching logic.

Look at how clean data fetching becomes.

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

function ProductList() {
  const queryClient = useQueryClient();
  
  // Fetching and caching products
  const { data: products, isLoading } = useQuery({
    queryKey: ['products'],
    queryFn: () => fetch('/api/products').then(res => res.json())
  });

  // Adding a new product
  const mutation = useMutation({
    mutationFn: (newProduct) => fetch('/api/products', { 
      method: 'POST', 
      body: JSON.stringify(newProduct) 
    }).then(res => res.json()),
    onSuccess: () => {
      // Invalidate the cache, triggering a fresh fetch
      queryClient.invalidateQueries({ queryKey: ['products'] });
    }
  });

  if (isLoading) return <div>Loading...</div>;
  return (
    <div>
      {products.map(p => <div key={p.id}>{p.name}</div>)}
      <button onClick={() => mutation.mutate({ name: 'New Item' })}>
        Add Product
      </button>
    </div>
  );
}

See the pattern? React Query manages the server’s truth. Your components don’t store API results in state; they subscribe to a query cache. This eliminates entire classes of bugs related to stale data.

So how do they work together? Beautifully. They occupy different layers. Your Zustand store might hold a searchTerm string for filtering. Your React Query hook fetches the full list of products. You combine them locally in the component.

function ProductDashboard() {
  // Client state: the user's search input
  const searchTerm = useUiStore((state) => state.searchTerm);
  
  // Server state: the full product list
  const { data: allProducts } = useQuery({
    queryKey: ['products'],
    queryFn: fetchProducts
  });

  // Derived local state: filtering the cached list
  const filteredProducts = allProducts?.filter(p => 
    p.name.toLowerCase().includes(searchTerm.toLowerCase())
  ) || [];

  return (
    <>
      <SearchInput />
      <ProductGrid products={filteredProducts} />
    </>
  );
}

Zustand holds the volatile, client-specific data. React Query holds the authoritative server data. The component is the meeting point, combining them for presentation. This separation is the key to scalability. New developers on the team can instantly understand what type of state they are dealing with and which tool to use.

What happens when you need to share server data with your client state? You pass it as a simple function argument. For example, a Zustand action that updates a user preference might need the current user ID from React Query’s cache. You would read the ID from the query cache and pass it to the Zustand action. The libraries remain independent, communicating through your application code.

This approach also makes testing much easier. You can test your Zustand stores in isolation, mocking any functions. You can test your components using React Query’s testing utilities to simulate different server states. The boundaries are clear.

For me, adopting this pattern was a turning point. It stopped the endless debates about “where to put state.” The rule became simple: Is this from a server? Use React Query. Is this for the UI? Use Zustand. This mental model reduces decision fatigue and lets you focus on building features.

The combination is lean. You avoid the overhead of a heavyweight framework. You get incredible developer experience with full TypeScript support. Your app’s performance improves because React Query’s intelligent caching prevents unnecessary network requests. Your code becomes more maintainable because the responsibilities are clearly divided.

Have you tried managing server cache with a global client store before? How did you handle refetching on window focus or reconnection? React Query handles these edge cases out of the box, allowing you to delete hundreds of lines of fragile logic.

I encourage you to try this setup in your next project. Start by moving all your fetch calls into React Query hooks. Then, identify the UI state that’s cluttering your components and lift it into a small Zustand store. You’ll likely be surprised by how much simpler your components become.

If this approach to structuring your state makes sense, I’d love to hear about your experience. Did it help clarify your code? What challenges did you face? Please share your thoughts in the comments below—discussing these patterns helps everyone learn. If you found this useful, consider liking and sharing it with other developers who might be wrestling with their state management strategy. Let’s build more understandable applications, 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 to Scale Real-Time Apps with Socket.io and Redis Adapter

Learn how to scale real-time features across multiple servers using Socket.io and Redis Adapter for seamless communication.

Blog Image
How to Build a Production-Ready GraphQL API with NestJS, Prisma, and Redis: Complete Guide

Learn to build a production-ready GraphQL API using NestJS, Prisma & Redis caching. Complete guide with authentication, optimization & deployment tips.

Blog Image
Complete Guide to Integrating Svelte with Supabase for Modern Full-Stack Web Applications

Learn how to integrate Svelte with Supabase for modern web apps. Build reactive frontends with real-time data, authentication, and PostgreSQL backend. Start now!

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 applications. Build robust database operations with seamless TypeScript support.

Blog Image
Build High-Performance GraphQL APIs with Apollo Server, DataLoader, and Redis Caching

Learn to build scalable GraphQL APIs with Apollo Server, DataLoader & Redis caching. Master N+1 problem solutions, query optimization & real-time features.

Blog Image
Building Type-Safe Event-Driven Microservices with NestJS, RabbitMQ, and Prisma: Complete Tutorial

Learn to build type-safe event-driven microservices with NestJS, RabbitMQ & Prisma. Complete guide with CQRS patterns, error handling & monitoring setup.