Zustand vs Redux? Why React Query Is the Missing Piece for Modern React State Management
Learn how Zustand and React Query simplify React state management by separating client and server state for cleaner, faster apps.
I remember the exact moment I started questioning my state management choices. I was staring at a Redux store that had grown into a living nightmare — over twenty slices, each holding everything from user authentication tokens to whether a dropdown was open. Changing one piece of data required updating three different reducers and praying I hadn’t broken something else. The worst part? Most of that state was just a cached copy of what the server already had. I felt like I was fighting my own code.
That experience made me step back and ask a simple question: do I actually need a single monolithic store for everything? The answer, I discovered, is a firm no. Modern React applications have two fundamentally different kinds of state — server state and client state — and trying to manage both with the same tool leads to unnecessary complexity, stale data, and fragile code.
Server state is anything that lives on a remote database. User profiles, product lists, notifications, comments — all of it comes from an API, can change without you knowing, and requires careful handling of loading and error states. Client state, on the other hand, is purely local. Things like whether a sidebar is open, which tab is active, or a form’s temporary input values. These have nothing to do with the server and should never be cached or synchronized remotely.
The moment I separated these two concerns, everything became cleaner. I use Zustand for client state and React Query for server state. Together they form a lightweight, full-stack state management system that is both powerful and easy to reason about.
Let me show you what I mean.
Why Zustand for client state
Zustand is a tiny library — just over a kilobyte — that gives you a store without the ceremony. You create a store with a simple function, and components subscribe to only the parts they need. No providers, no context, no reducer boilerplate.
Here’s how I set up a store for UI toggles:
import { create } from 'zustand';
const useUIStore = create((set) => ({
sidebarOpen: false,
activeModal: null,
theme: 'light',
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
openModal: (modalName) => set({ activeModal: modalName }),
closeModal: () => set({ activeModal: null }),
setTheme: (theme) => set({ theme }),
}));
export default useUIStore;
That’s it. I can use useUIStore in any component and only re-render when the slice I’m reading changes. No weird selector issues, no unnecessary re-renders. The API is so simple that even junior developers on my team pick it up in minutes.
Why React Query for server state
React Query (now TanStack Query) is designed to handle every aspect of server interaction. It caches data, background refetches, retries on failure, and keeps the UI in sync with the server automatically.
Consider a typical list of products fetched from an API:
import { useQuery } from '@tanstack/react-query';
function ProductList() {
const { data, isLoading, error } = useQuery({
queryKey: ['products'],
queryFn: () => fetch('/api/products').then((res) => res.json()),
});
if (isLoading) return <p>Loading products...</p>;
if (error) return <p>Failed to load products: {error.message}</p>;
return data.map((product) => <ProductCard key={product.id} product={product} />);
}
Notice how I didn’t write any code to handle caching or updating the UI when the data changes. React Query does that behind the scenes. When the user visits another page and comes back, the data is instantly available from the cache while a background refetch updates it. The UX feels snappy, and I wrote almost no state management logic.
But here’s the crucial part — I never store the product data in Zustand. Zustand only handles local UI state like whether the product list is displayed as a grid or list.
The integration pattern
The real power comes when you combine the two. You query the server with React Query, and you store user preferences or temporary selection state with Zustand.
Imagine a filter panel that lets users sort products by price or rating. The sort order is client state — it doesn’t need to be persisted to the server every time. But the actual product data is server state.
Here’s how I wire them together:
// Zustand store for sort preference
const useFiltersStore = create((set) => ({
sortBy: 'price',
setSortBy: (sort) => set({ sortBy: sort }),
}));
// Component
function FilteredProductList() {
const { sortBy, setSortBy } = useFiltersStore();
const { data: products, isLoading } = useQuery({
queryKey: ['products', sortBy],
queryFn: () => fetch(`/api/products?sort=${sortBy}`).then(res => res.json()),
});
return (
<div>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
<option value="price">Price</option>
<option value="rating">Rating</option>
</select>
{isLoading ? <p>Loading...</p> : products.map(p => <Product key={p.id} {...p} />)}
</div>
);
}
The filter UI writes to Zustand. React Query reads the current sort value from Zustand and refetches the data automatically whenever it changes. No manual refetch calls, no dispatching actions to a massive store. Just clean separation.
A personal rule I now live by
I have a simple mental model now: if the data comes from an API, it belongs in React Query. If the data exists only for UI behavior on the client, it belongs in Zustand.
What about form state? Forms are a gray area. If a form prefills with server data, I load the initial values from React Query, but the current form input values live in a local state (or a Zustand store if the form is complex). On submission, I call a mutation via React Query to save to the server. This keeps the two worlds separate.
Do you ever find yourself storing API responses in a global state just to avoid loading spinners? I used to do that all the time. React Query eliminates that temptation entirely. Its cache is smarter than any hand-rolled solution.
Handling mutations and invalidation
Another place where this integration shines is after a mutation. Let’s say the user updates their profile. With React Query, you can automatically invalidate related queries so the UI updates immediately.
import { useMutation, useQueryClient } from '@tanstack/react-query';
function ProfileEditor() {
const queryClient = useQueryClient();
const { sidebarOpen } = useUIStore(); // client state for modal display
const mutation = useMutation({
mutationFn: (newProfile) => fetch('/api/profile', {
method: 'PUT',
body: JSON.stringify(newProfile),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
// also close the modal using Zustand
useUIStore.getState().closeModal();
},
});
// ... rest of component
}
Notice I use useUIStore.getState().closeModal() inside the mutation’s success callback. That’s because the store can be accessed outside of a React component. This tiny feature of Zustand — the ability to get and set state without hooks — is incredibly useful for orchestrating UI changes after async operations complete.
When does this pattern break?
No solution is perfect. If you need real-time synchronization with WebSockets, you might add a third library like Socket.io on top. But for 95% of web applications, Zustand plus React Query is all you need.
What about authentication state? Auth tokens are interesting — they are both server state (verified by API) and client state (used to decide which UI to show). I keep the token in a Zustand store with persistence to localStorage, but I always verify it with a React Query query on app load. The token’s existence is local, but its validity is server-owned.
The simplicity payoff
Since adopting this separation, my codebase has become easier to debug. When I see a bug, I immediately know which tool to look at. Is the wrong data showing up? Check React Query. Is a modal not closing? Check Zustand. No more chasing through reducers trying to figure out which action mutated the wrong piece of state.
The best part is onboarding new developers. I can explain the entire state management architecture in five minutes: “React Query fetches and caches server data. Zustand handles UI state. That’s it.” They understand it immediately because the division is natural.
If you are tired of over-engineered state management, try this approach for your next project. You might be surprised how much mental overhead disappears.
Your turn
Now I want to hear from you. Have you ever used Zustand with React Query? What was your biggest struggle with state management before making the switch? Drop your thoughts in the comments below, share this article with a teammate who is still wrestling with a monolithic store, and give it a like if you found it useful. I read every comment and I’d love to learn from your experience.
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