I remember the exact moment I realized my React app was in trouble. I had a single Redux store holding everything: user profiles, blog posts, search results, sidebar visibility, and even the text inside a temporary input field. The store was enormous. Every time I fetched new data, I had to dispatch actions to manually update slices. And the bugs? Brutal. A background refetch would overwrite a user’s unsaved form data because I had stored it in the same object as the server response. I needed a better way to draw a line between what belongs to the server and what belongs to the client.
That’s when I started using Zustand and React Query together. Not as competitors, but as partners with clear jobs. React Query handles everything that comes from an API. Zustand handles everything that only exists in the browser. The result is a codebase that breathes easier. Let me show you how this works in practice, and why you might want to adopt it too.
The core confusion
Most developers I talk to treat state like a single bucket. They stuff everything inside one state manager and then fight with it daily. Have you ever asked yourself why a cached list of products should share a reducer with a toggle for dark mode? It doesn’t make sense. Server state has different rules. It comes from a network request, can become stale, needs loading indicators, and requires error handling. Client state is local, fast, and only lives as long as the user interacts with the page.
React Query was built for server state. It gives you automatic caching, background refetching, pagination, and optimistic updates with almost no boilerplate. Zustand was built for client state. It’s tiny, has no providers, and lets you create stores with plain functions. When you combine them, you stop asking your state manager to do impossible things.
Building a practical example
Let’s say I’m building a task management dashboard. I have a list of tasks that come from an API. I also have a filter panel, a modal for adding tasks, and a checkbox that hides completed items. The tasks are server state. The filter selection, modal open/close, and the “hide completed” flag are client state.
Here’s how I set up Zustand for the client state:
// clientStore.js
import { create } from 'zustand';
const useClientStore = create((set) => ({
filterStatus: 'all',
modalOpen: false,
hideCompleted: false,
setFilterStatus: (status) => set({ filterStatus: status }),
openModal: () => set({ modalOpen: true }),
closeModal: () => set({ modalOpen: false }),
toggleHideCompleted: () =>
set((state) => ({ hideCompleted: !state.hideCompleted })),
}));
Now the server state via React Query:
// useTasksQuery.js
import { useQuery } from '@tanstack/react-query';
async function fetchTasks() {
const response = await fetch('/api/tasks');
if (!response.ok) throw new Error('Network error');
return response.json();
}
export function useTasksQuery() {
return useQuery({
queryKey: ['tasks'],
queryFn: fetchTasks,
staleTime: 5 * 60 * 1000, // 5 minutes
refetchInterval: 30 * 1000, // poll every 30s
});
}
Notice something? The React Query hook doesn’t even know about the client store. That’s by design. I keep them separate.
Integration point: reading server state inside client logic
But sometimes the two worlds need to interact. For example, when I filter tasks locally, I need the server data and the client filter together. I do that inside the component, not inside either store.
// TaskDashboard.jsx
import { useTasksQuery } from './useTasksQuery';
import useClientStore from './clientStore';
export default function TaskDashboard() {
const { data: tasks, isLoading, error } = useTasksQuery();
const filterStatus = useClientStore((state) => state.filterStatus);
const hideCompleted = useClientStore((state) => state.hideCompleted);
if (isLoading) return <div>Loading tasks...</div>;
if (error) return <div>Failed to load tasks.</div>;
const filteredTasks = tasks.filter((task) => {
if (hideCompleted && task.completed) return false;
if (filterStatus !== 'all' && task.status !== filterStatus) return false;
return true;
});
return (
<div>
{/* render filteredTasks */}
</div>
);
}
This is clean because React Query provides the data, Zustand provides the filter criteria, and the component handles the local transformation. No mixing of concerns.
Writing data: using Zustand to trigger React Query mutations
What about creating a new task? I open a modal (client state), fill in the form, and submit. The submission is a server operation. I use React Query’s useMutation. But I also want to close the modal after success. That’s a perfect moment for Zustand to talk to React Query.
import { useMutation, useQueryClient } from '@tanstack/react-query';
import useClientStore from './clientStore';
export function useCreateTaskMutation() {
const queryClient = useQueryClient();
const closeModal = useClientStore((state) => state.closeModal);
return useMutation({
mutationFn: (newTask) =>
fetch('/api/tasks', {
method: 'POST',
body: JSON.stringify(newTask),
headers: { 'Content-Type': 'application/json' },
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
closeModal(); // client state update after server success
},
});
}
See? The mutation itself lives inside React Query. The side effect of closing the modal is handled by a Zustand action. They work in harmony.
Why this separation scales
As your app grows, new developers can immediately understand where to put state. If it comes from a server, it goes into a React Query hook. If it’s a UI toggle, it goes into Zustand. You avoid the dreaded “I don’t know why this value changed” problem. And because Zustand is so small, you never feel tempted to abuse it by storing hundreds of fetch results in it. Have you ever looked at a large Redux store and wondered which parts are ephemeral UI state and which are cached API data? That confusion vanishes.
Another benefit: React Query handles deduplication. If two components both call useTasksQuery, React Query makes a single network request and shares the result. If you had put tasks inside Zustand, you would have to manually ensure only one fetch happens. That’s extra code and a source of race conditions.
Performance wins without effort
Zustand stores are minimal. They only trigger re-renders when the accessed slice changes. React Query does the same with its selectors. When you combine both, you end up with highly granular subscriptions. A filter dropdown in one part of the screen can update without forcing the entire task list to re-render. The integration is natural because neither library tries to own the other.
A personal tip
I used to write a lot of custom hooks that did fetch-and-store patterns. They fetched data, then dispatched it to a global store. That worked for small apps, but after a few months I always forgot which store held live data and which held stale copies. Now I have a simple rule: if the data needs to survive a page refresh, it comes from the API via React Query. If it resets when the page reloads, it stays in Zustand. That clarity alone saved me hours of debugging.
Questions worth asking yourself
When was the last time you accidentally cached a temporary form value? How often do you invalidate a store because the API data changed? If you’re using a single global store for everything, how long would it take you to trace a bug that occurs when a background refetch overwrites a user’s unsaved filter? These are the cracks that the Zustand–React Query combination fills.
Final thoughts – and a small request
If this article helped you see the separation between client and server state more clearly, I’d genuinely appreciate it if you hit the like button, share it with a teammate who might be struggling with state management, and drop a comment below with your own experience. I read every reply, and I’m always curious how other teams solve these problems. The best code is the code that feels obvious months later. This integration is a step in that direction.
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