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