js

Zustand and React Query: A Smarter State Management Pattern for React Apps

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

Zustand and React Query: A Smarter State Management Pattern for React Apps

When I first started building React applications, I made the same mistake most developers make. I tried to manage every piece of state—whether it came from the server or lived only in the browser—inside a single global store. I used Redux for everything: user data, UI toggles, form inputs, and API responses. It worked, but it was fragile. Every time I needed to fetch new data, I had to manually dispatch actions, update slices, and handle loading and error states by hand. The code grew fat, and I grew tired.

Then I discovered a simpler way: separate your concerns. Server state and client state are different animals, so why treat them the same? React Query handles server state with caching, background refetching, and optimistic updates. Zustand handles client state with a tiny, hook-based store that requires zero boilerplate. When you combine them, you get a clean, fast, and maintainable architecture. Let me show you how.

Have you ever refreshed a page and lost your sidebar state while the server data stayed in the cache? That’s exactly the problem this separation solves.

The Problem with a Single Global Store

Imagine you have a shopping app. You need to show the user’s profile info (server state), a cart modal (UI state), and a list of products (server state). In a traditional Redux setup, you might store the fetched profile and products in the same reducer as the modal visibility flag. Now when you want to refetch products because the user filters by price, you have to write a thunk, dispatch a pending action, then a success action, and manually merge the new data. Meanwhile, the modal state sits in the same store, untouched but tangled in the same middleware chain. It works, but it’s heavy.

React Query removes that weight entirely. You simply call a hook like useQuery and declare your fetch function. React Query manages the cache, the loading state, the error state, and even background refetches. You never touch a reducer for server data again.

A Simple Zustand Store for Client State

Zustand is so simple it almost feels wrong. Here’s a store for a sidebar and a modal:

import { create } from 'zustand'

const useUIStore = create((set) => ({
  sidebarOpen: false,
  modalOpen: false,
  toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
  openModal: () => set({ modalOpen: true }),
  closeModal: () => set({ modalOpen: false }),
}))

No Provider, no action types, no reducers. Just a function that returns an object with methods. You use it in any component:

import { useUIStore } from './stores/uiStore'

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

  return <button onClick={toggleSidebar}>{sidebarOpen ? 'Close' : 'Open'}</button>
}

That’s it. If the user refreshes the page, the sidebar will reset to false—which is exactly what you want for ephemeral client state.

Fetching Server Data with React Query

Now let’s fetch a list of products from an API. With React Query, you define a query key and a fetcher:

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

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

In your component, you get data, isLoading, isError, and many more. No manual loading flags. No dispatches.

function ProductList({ category }) {
  const { data, isLoading, isError } = useProducts(category)

  if (isLoading) return <div>Loading...</div>
  if (isError) return <div>Error</div>

  return data.map(product => <Product key={product.id} {...product} />)
}

Now think about this: the product data might change on the server every few seconds. React Query can refetch it in the background and update the UI seamlessly. Meanwhile, your Zustand store stays out of the way, only managing UI state.

But what happens when you need to combine both? For example, you want to open a modal with product details fetched from the server. That’s where integration shines.

Bringing Them Together

The magic happens when you use Zustand to control UI behavior that depends on server data. Let’s say you click a product to see its details. You need to store which product is currently selected (client state) and then fetch that product’s details (server state). Here’s how I do it.

First, add a selectedProductId to the Zustand UI store:

const useUIStore = create((set) => ({
  // ... previous state
  selectedProductId: null,
  selectProduct: (id) => set({ selectedProductId: id }),
  clearSelection: () => set({ selectedProductId: null }),
}))

Then in a component, when the user clicks a product, set the selected ID. A separate component uses that ID to fetch the details:

function ProductCard({ product }) {
  const selectProduct = useUIStore((state) => state.selectProduct)

  return <div onClick={() => selectProduct(product.id)}>{product.name}</div>
}

function ProductDetailView() {
  const selectedProductId = useUIStore((state) => state.selectedProductId)
  const { data, isLoading } = useQuery({
    queryKey: ['product', selectedProductId],
    queryFn: () => fetch(`/api/products/${selectedProductId}`).then(res => res.json()),
    enabled: !!selectedProductId, // only run when an ID exists
  })

  if (!selectedProductId) return <div>Select a product</div>
  if (isLoading) return <div>Loading...</div>

  return <div>{data.name}: {data.description}</div>
}

Notice the enabled option. It tells React Query to wait until a valid ID is available. This prevents unnecessary fetching when no product is selected. And the Zustand store keeps the ID neatly separated from the server fetching logic.

Have you ever had to write a complex reducer just to track which item is selected and then manually trigger a fetch? I have, and it was painful. This approach is clear and direct.

What About Mutations?

Mutations (creating, updating, deleting) are also server state matters. React Query provides useMutation for that. After a mutation succeeds, you often need to invalidate related queries to refetch fresh data. That’s handled by calling queryClient.invalidateQueries. Meanwhile, you might want to close a modal or show a toast—client state actions. Here’s an example:

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

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

  const mutation = useMutation({
    mutationFn: (newProduct) => fetch('/api/products', {
      method: 'POST',
      body: JSON.stringify(newProduct),
    }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['products'] })
      closeModal() // client state update
    },
  })

  // ... form logic
}

The mutation invalidates the product list query and closes the modal. React Query handles the server cache invalidation; Zustand handles the UI closure. Each library does what it was built for.

Common Pitfalls and How to Avoid Them

One mistake people make is storing server data redundantly in Zustand. For example, after a fetch, they copy the result into a Zustand store. Don’t do that. React Query already caches it. If you duplicate it, you’ll end up with two sources of truth that can get out of sync. Only use Zustand for truly client-only values: sidebar toggles, form drafts, modal states, selected item IDs (not the data itself).

Another pitfall: putting the React Query client inside Zustand. The queryClient is a singleton that belongs to React Query’s context. You don’t need to store it in Zustand. Just import it where needed or use hooks.

A Real World Example from My Work

I maintain a dashboard that shows live metrics. The metrics come from an API that updates every minute. I use React Query with a staleTime of 30 seconds and a refetchInterval of 60 seconds. The UI has a sidebar that collapses, a theme toggle (light/dark), and a query parameter for the time range. The sidebar and theme are stored in Zustand and persisted to localStorage using Zustand’s persist middleware. The time range is also client state because it’s just a filter that affects which query key we use to fetch metrics. I store the time range in Zustand. When the user changes it, I pass the new key to useQuery, and React Query fetches the relevant data.

This separation made my code half the size it used to be. And debugging is a dream: if something goes wrong with the UI, I look at Zustand. If the data is wrong, I look at React Query. No cross-contamination.

Why You Should Care

If you’re still using a single monolithic store to handle everything, you’re adding complexity you don’t need. Zustand and React Query are both small, focused libraries. Together they give you a clear mental model: server data flows through React Query, UI state flows through Zustand. The boundaries are obvious, and the code is easier to read, test, and maintain.

Now, think about your current project. How much of your Redux store is actually server data that could be replaced with two lines of useQuery? How many action creators and reducers could vanish? It’s a liberating feeling.

Let’s Wrap It Up

I hope this walkthrough has given you a practical picture of how to integrate Zustand with React Query. The approach is straightforward, scalable, and a joy to work with. If you haven’t tried it yet, I encourage you to start small—migrate one feature and feel the difference.

If you found this useful, please like and share this article with a teammate who might be struggling with state management. And leave a comment below—I’d love to hear about your own experiences or questions about using Zustand and React Query together. Your feedback helps me write better guides.


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
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
Building Type-Safe Event-Driven Microservices: NestJS, RabbitMQ & Prisma Complete Guide

Learn to build scalable event-driven microservices with NestJS, RabbitMQ, and Prisma. Master type-safe messaging, error handling, and testing strategies for robust distributed systems.

Blog Image
How to Integrate Next.js with Prisma ORM: Complete Type-Safe Database Setup Guide

Learn how to integrate Next.js with Prisma ORM for type-safe full-stack applications. Build scalable web apps with seamless database operations and TypeScript support.

Blog Image
Build High-Performance Distributed Rate Limiting with Redis, Node.js and Lua Scripts: Complete Tutorial

Learn to build production-ready distributed rate limiting with Redis, Node.js & Lua scripts. Covers Token Bucket, Sliding Window algorithms & failover handling.

Blog Image
Production-Ready Event-Driven Microservices: NestJS, RabbitMQ, and MongoDB Architecture Guide

Learn to build production-ready microservices with NestJS, RabbitMQ & MongoDB. Master event-driven architecture, async messaging & distributed systems.

Blog Image
How to Supercharge Your Frontend Workflow with Vite, Tailwind CSS, and PostCSS

Boost development speed and reduce CSS bloat by integrating Vite, Tailwind CSS, and PostCSS into one seamless workflow.