Zustand vs React Query: The Cleanest Way to Manage React State
Learn when to use Zustand for client state and React Query for server state to build cleaner, scalable React apps. Read the guide now.
I remember the exact moment I stopped arguing with my team about where to put state. We were building a dashboard that displayed real-time analytics, user preferences, and a dark mode toggle. The data was coming from three different APIs, the UI had dozens of interactive filters, and the whole thing felt like a plate of spaghetti thrown against a wall. Every time we tried to fetch new data, we had to clear the Redux store, update reducers, and rewrite selectors. It was fragile, verbose, and impossible to test.
So I sat down and asked myself: why are we using a state manager built for local UI concerns to handle server data? That question led me to a much cleaner architecture: let Zustand handle the client-side state — the things that live only inside the browser — and let React Query manage the server-side state — the things that need to come from or go to an API. The combination is not only elegant; it’s scalable.
Let me explain what I mean. Client‑side state is anything that doesn’t need persistence on a remote server. Think of a sidebar being open or closed, a selected tab in a settings panel, or the user’s preferred theme. These values are simple, fast, and they change frequently without any network call. Server‑side state, on the other hand, is everything that lives on the backend — a list of users, a product catalog, an order history. It is asynchronous, can become stale, and needs caching, refetching, and invalidation.
When you try to manage both with the same tool, you end up writing custom middleware to deduplicate requests, polling mechanisms that burn battery life, and reducers that look like a copy of your backend schema. That’s madness. Instead, you can let each library do what it does best.
Zustand is a tiny state manager. Its API is just a hook with a setter function. There are no action creators, no dispatch calls, no boilerplate factories. You define a store, and you use it directly in your components.
import { create } from 'zustand';
const useStore = create((set) => ({
sidebarOpen: true,
theme: 'dark',
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setTheme: (theme) => set({ theme }),
}));
That’s it. Now any component can call useStore(state => state.sidebarOpen) and reactively update when the sidebar toggles. No reducers, no actions, no connected containers. For client‑only state, this is all you need.
Now, what about data that comes from an API? This is where React Query steps in. It provides a hook called useQuery that fetches data, caches it, automatically refetches when the window is refocused or when the network reconnects, and handles loading and error states out of the box.
import { useQuery } from '@tanstack/react-query';
function Dashboard() {
const { data, isLoading, error } = useQuery({
queryKey: ['analytics', { period: 'last7days' }],
queryFn: () => fetch('/api/analytics').then(res => res.json()),
});
if (isLoading) return <Spinner />;
if (error) return <ErrorBanner message={error.message} />;
return <Chart data={data} />;
}
Think about what just happened there. React Query automatically caches the response, tracks the query key, and if any other component requests the same key, it uses the cached data without a second network call. Stale data is transparently refetched in the background. You don’t have to write boilerplate for loading states, error handling, or data deduplication.
Now here is where the magic happens. You can combine them. Let’s say you have a filter panel that uses Zustand to store the currently selected filter values — those are client‑side choices. When the user changes a filter, you don’t want to fetch data immediately on every keystroke. You want to debounce, and then refetch the server data using the new filter values.
import { create } from 'zustand';
import { useQuery } from '@tanstack/react-query';
import { useDebounce } from 'use-debounce';
const useFilterStore = create((set) => ({
searchTerm: '',
category: 'all',
setSearchTerm: (term) => set({ searchTerm: term }),
setCategory: (cat) => set({ category: cat }),
}));
function ProductList() {
const { searchTerm, category } = useFilterStore();
const [debouncedSearch] = useDebounce(searchTerm, 300);
const { data, isLoading } = useQuery({
queryKey: ['products', { search: debouncedSearch, category }],
queryFn: () => fetch(`/api/products?q=${debouncedSearch}&cat=${category}`).then(res => res.json()),
enabled: !!debouncedSearch, // Only fetch when there's a search term
});
return (
<div>
<input
value={searchTerm}
onChange={(e) => useFilterStore.getState().setSearchTerm(e.target.value)}
/>
{isLoading ? <Loading /> : data?.products.map(p => <ProductCard key={p.id} product={p} />)}
</div>
);
}
Notice how the filter values live entirely inside Zustand. They are fast to update, cause no network overhead, and are isolated from the async data flow. The React Query hook watches those values via its query key. When the key changes (because the user types a new search term after the debounce), React Query automatically cancels the previous request and fires a new one. No manual deduction of stale data, no manual cache invalidation. It’s automatic.
Have you ever tried to do this with Redux? You’d need thunks, middleware, custom selectors to deal with debouncing, and a lot of boilerplate just to avoid race conditions. With Zustand and React Query, the architecture is declarative and clean.
I’ve used this pattern on a project that had over 100 interactive filters across multiple dashboards. The total state management code in Zustand was under 200 lines. React Query handled all API interactions. The performance was snappy because Zustand never re‑renders components that don’t consume the changed slice. React Query’s built‑in stale‑while‑revalidate strategy meant that data felt instant even when it was being refetched in the background.
One personal touch: I used to write a lot of custom hooks that merged local and server state, and they were always brittle. Every time the API changed, I had to update my local store, my custom hook, and my cache invalidation logic. Now, I simply keep Zustand stores for UI concerns (like which modal is open, which theme is active) and React Query for everything else. When the backend team changes a field name, I only adjust the query function. The client state stays untouched.
What about mutations? React Query has useMutation for that. You can call a mutation, and on success, invalidate related queries so they refetch.
import { useMutation, useQueryClient } from '@tanstack/react-query';
function AddProductForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newProduct) => fetch('/api/products', {
method: 'POST',
body: JSON.stringify(newProduct),
headers: { 'Content-Type': 'application/json' },
}),
onSuccess: () => {
// Invalidate and refetch the product list
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
const handleSubmit = (event) => {
event.preventDefault();
const formData = new FormData(event.target);
mutation.mutate(Object.fromEntries(formData));
};
return (
<form onSubmit={handleSubmit}>
<input name="name" required />
<button type="submit">{mutation.isLoading ? 'Adding…' : 'Add Product'}</button>
{mutation.isError && <p>Error: {mutation.error.message}</p>}
</form>
);
}
The mutation updates the server, and then the product list automatically refreshes. No manual store updates, no optimistic updates that break — unless you write them, and React Query supports that too with onMutate.
Does this mean Zustand and React Query never overlap? Of course they can. You might want to keep a cached value from React Query and derive a local state from it. That’s fine. Zustand’s store is not a global jail; you can read from React Query inside a Zustand action if needed. But best practice is to keep them separate: let Zustand be the source of truth for UI decisions, and let React Query be the source of truth for server data.
Now, let’s talk about scaling. When your app grows, you’ll have dozens of different queries. React Query handles caching, deduplication, and garbage collection automatically. Zustand remains lean because it only cares about the user’s immediate interactions. If you need to persist client state (like a theme preference) across sessions, Zustand has a simple persist middleware.
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const useThemeStore = create(
persist(
(set) => ({
theme: 'light',
setTheme: (theme) => set({ theme }),
}),
{ name: 'theme-preference' }
)
);
That stores the theme in localStorage automatically. React Query handles its own cache persistence if needed via persistQueryClient. The boundaries stay clear.
I can’t count how many times I’ve seen a codebase where a developer tried to manage a simple dropdown’s open/close state inside a global Redux store, and then had to create a whole saga just to close it on navigation. With Zustand, you just call set({ dropdownOpen: false }). With React Query, you just change the query key. The complexity disappears.
So the next time you start a new React project, or refactor an existing one, ask yourself: who owns this piece of state? If it’s a value that only matters inside the browser right now — like whether a tooltip is visible — put it in Zustand. If it’s data that comes from a server or goes to a server — like a list of orders — put it in React Query. The two libraries are not competitors; they are a perfect pair.
I hope this helps you think about state differently. If you liked this approach, give this article a thumbs up, share it with a teammate who’s still fighting Redux thunks, and leave a comment about your own state management horror story — I’d love to hear how you solved it.
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