Zustand and React Query: The Cleanest React State Management Pattern
Learn how Zustand and React Query separate client and server state in React apps to reduce bugs, improve performance, and scale cleanly.
I’ve spent years building React applications, and I’ve made every mistake you can imagine with state management. I remember one project where we stored everything — user authentication, modal visibility, even paginated API responses — inside a single Redux store. The result was a tangled mess of reducers, actions, and selectors that made even a simple feature update take twice as long. That experience led me to search for a simpler, more disciplined approach. That’s when I discovered the clean separation between client state and server state, and the perfect pair of tools to handle each: Zustand and React Query.
Have you ever wondered why your app feels sluggish even after a perfect API response? The answer often lies in mixing local UI state with cached server data. By the end of this article, you’ll see how to split these responsibilities without adding complexity, and you’ll walk away with a practical blueprint you can apply today.
Let me start with a simple truth: not all state is created equal. Some state belongs entirely to the user’s interaction — a sidebar that’s open or closed, a dropdown selection, a dark mode toggle. This is client state. It lives and dies inside the browser. Other state comes from a backend — a list of products, a user’s profile, a dashboard of live metrics. This is server state. It needs to be fetched, cached, invalidated, and kept in sync with the source of truth.
Zustand handles the first kind beautifully. It’s a tiny, unopinionated library that gives you a store without boilerplate. Think of it as a smart global variable that any component can read and write directly. React Query handles the second kind. It manages the entire lifecycle of server data: loading, success, error, refetching, and stale-while-revalidate strategies.
When you use them together, you stop fighting against yourself. No more storing an API response in Zustand and then manually refetching it. No more duplicating data across components. The lines are clean. The code is lean.
Here’s how I usually set up a Zustand store for client state. Imagine a simple sidebar toggle and a selected theme:
import { create } from 'zustand';
const useUIStore = create((set) => ({
sidebarOpen: false,
theme: 'light',
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setTheme: (theme) => set({ theme }),
}));
That’s it. No provider, no reducer mapping, no context. Any component can call useUIStore((state) => state.sidebarOpen) and reactively update. I use this for all UI-level decisions that don’t involve the server.
Now for server state. Let’s say I need to fetch a list of users from an API. With React Query, I write:
import { useQuery } from '@tanstack/react-query';
const fetchUsers = async () => {
const res = await fetch('/api/users');
if (!res.ok) throw new Error('Failed to fetch users');
return res.json();
};
export const useUsers = () => {
return useQuery({ queryKey: ['users'], queryFn: fetchUsers });
};
This hook gives me data, isLoading, isError, and refetch. React Query automatically caches the response, refetches when the window regains focus, and invalidates when I mutate data. I never have to write a single useEffect or useState for fetching again.
Have you ever accidentally stored an API response in your local state only to have it become stale? React Query solves that by default.
The real magic happens when you integrate the two. Because Zustand’s store is completely external to React Query, they don’t interfere. But they can cooperate beautifully. For example, a common pattern is to let a user’s local selection (stored in Zustand) drive a query parameter in React Query.
const useUserList = () => {
const searchTerm = useUIStore((state) => state.searchTerm);
return useQuery({
queryKey: ['users', searchTerm],
queryFn: () => fetchUsers(searchTerm),
});
};
When the user types into a search box, Zustand updates searchTerm. React Query sees the query key change and automatically refetches with the new parameter. No wiring, no debouncing logic inside the store. The separation keeps each piece simple.
I also use Zustand to hold transient UI states that depend on server data. For instance, when a user clicks “Delete” on a user row, I want to show a confirmation modal. The selectedUserId lives in Zustand. The actual deletion uses React Query’s useMutation.
import { useMutation, useQueryClient } from '@tanstack/react-query';
import useUIStore from './store';
const deleteUser = async (id) => {
const res = await fetch(`/api/users/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Delete failed');
};
export const useDeleteUser = () => {
const queryClient = useQueryClient();
const closeModal = useUIStore((state) => state.closeConfirmModal);
return useMutation({
mutationFn: deleteUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
closeModal();
},
});
};
The mutation updates the server state (via invalidation), and then Zustand cleans up the client state (closes the modal). The two libraries never touch each other’s data, but they orchestrate a seamless user experience.
You might be wondering: why not just use one library for everything? I’ve seen teams try to stuff server state into Zustand. It works for a while, but then you need to handle retries, caching, background refetching, pagination, and optimistic updates. You end up rewriting React Query. Conversely, using React Query for UI state is overkill — you don’t need a query key for a modal’s open/close flag.
The integration scales effortlessly. As your app grows, you can split Zustand stores by domain — one for UI, one for auth tokens, one for form state — without affecting performance. React Query handles the heavy lifting of server synchronization, and you gain a mental model that’s easy to teach to new team members.
Here’s a personal touch: I once inherited a codebase where a single Redux store held dozens of API responses, each with its own loading flag. Debugging was a nightmare. After migrating to Zustand + React Query, the bundle size dropped by 50%, and the number of bugs related to stale data fell to nearly zero.
Now, what about cases where you need to share server data across the application in a non-query context? For example, I might want to show the current user’s name in a header without re-fetching. I simply read from React Query’s cache directly:
import { useQueryClient } from '@tanstack/react-query';
const UserName = () => {
const queryClient = useQueryClient();
const user = queryClient.getQueryData(['user']);
return <span>{user?.name}</span>;
};
No Zustand needed. The cache is the source of truth for server data. Only when I need to write that user data locally (like editing a draft profile) do I copy it into a Zustand slice.
Let me leave you with a final code example that ties everything together — a simple dashboard with a filter dropdown (client state) and a list of tasks (server state):
// store.js
import { create } from 'zustand';
const useDashboardStore = create((set) => ({
statusFilter: 'all',
setStatusFilter: (filter) => set({ statusFilter: filter }),
}));
// useTasks.js
import { useQuery } from '@tanstack/react-query';
import useDashboardStore from './store';
const fetchTasks = async (status) => {
const url = status === 'all' ? '/api/tasks' : `/api/tasks?status=${status}`;
const res = await fetch(url);
return res.json();
};
export const useTasks = () => {
const statusFilter = useDashboardStore((s) => s.statusFilter);
return useQuery({
queryKey: ['tasks', statusFilter],
queryFn: () => fetchTasks(statusFilter),
});
};
// Dashboard.jsx
import { useTasks } from './useTasks';
import useDashboardStore from './store';
const Dashboard = () => {
const { data, isLoading } = useTasks();
const statusFilter = useDashboardStore((s) => s.statusFilter);
const setStatusFilter = useDashboardStore((s) => s.setStatusFilter);
if (isLoading) return <div>Loading...</div>;
return (
<div>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="all">All</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
</select>
<ul>
{data.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
</div>
);
};
Notice how the filter value is read from Zustand, but the data fetching is handled entirely by React Query. When the filter changes, React Query refetches with the new query key. The component stays clean and declarative.
If you’ve ever felt overwhelmed by state management in React, I hope this article shows you that clarity is possible. The separation of concerns isn’t just a theoretical ideal — it’s a practical approach that reduces bugs, improves performance, and makes your codebase a joy to work with.
Now I’d like to ask you: have you tried separating client and server state in your own projects? What challenges did you face? Share your thoughts in the comments below — I’d love to learn from your experience. And if you found this helpful, hit the like button and subscribe to my newsletter for more practical frontend patterns.
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