I still remember the day I inherited a React codebase where half the components were fighting over a single Redux store. A dropdown would toggle and trigger a re-fetch of user profiles. A modal would open and somehow invalidate the entire product catalog. It was chaos. That experience forced me to think deeply about what state really means in a frontend application. You see, not all state is created equal. Some state belongs to the user’s current session — the open tabs, the selected filters, the theme preference. Other state belongs to the server — the list of orders, the user’s profile details, the inventory counts. Mixing these two types leads to unnecessary re-renders, stale data, and a lot of frustration.
That is why I started looking for a better way. I came across Zustand for client-side state and React Query for server-side state. At first, I was skeptical. Why two libraries when one global store could handle everything? But after using this pairing in several production applications, I realized it is the cleanest architecture I have ever worked with. Let me walk you through how this works and why you should care.
First, let’s talk about client state. This is anything that exists only in the browser and has no meaning on the server. Things like whether a sidebar is collapsed, which tab is active, or what the user typed in a search box before hitting submit. For this, Zustand is perfect. It is tiny — under one kilobyte — and requires no configuration. You create a store with create and define your state and actions.
import { create } from 'zustand';
const useUIStore = create((set) => ({
sidebarOpen: true,
activeModal: null,
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
openModal: (modalName) => set({ activeModal: modalName }),
closeModal: () => set({ activeModal: null }),
}));
That is it. No providers, no reducers, no boilerplate. You can use this store in any component by calling useUIStore() and destructuring what you need. Zustand automatically tracks dependencies and only re-renders components that use the changed slice.
Now, server state is a different beast. Data from an API has its own lifecycle: loading, error, stale, caching, refetching. Using a global store to manage this means you have to manually handle caching, deduplication, background updates, and optimistic updates. That is a lot of work. React Query was built exactly for this. It manages the entire server state lifecycle for you.
import { useQuery } from '@tanstack/react-query';
import { fetchProducts } from './api';
function ProductList() {
const { data, isLoading, error } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
});
if (isLoading) return <div>Loading products...</div>;
if (error) return <div>Error loading products</div>;
return data.map((product) => <div key={product.id}>{product.name}</div>);
}
Notice how data is automatically cached. Subsequent requests for the same queryKey will return the cached version while React Query silently refetches in the background if needed. This frees you from writing loading spinners and error boundaries by hand.
Have you ever written a useEffect to fetch data and then had to clean up the promise on unmount? React Query does that for you. It also handles pagination, infinite scrolling, and mutation invalidation with almost no extra code.
The real magic happens when you combine the two. Imagine you are building an e-commerce dashboard. The user can open and close a cart sidebar, and the sidebar shows the actual items in the cart. The sidebar’s open/closed state is client state — it should not be fetched from the server. The cart items are server state — they need to be fetched and kept fresh.
In this setup, Zustand holds the cartOpen boolean and a function to toggle it. React Query holds the cart items fetched from /api/cart. When the user opens the sidebar, you use Zustand to set cartOpen: true. The sidebar component then uses React Query’s useQuery to fetch the cart items. If the data is already cached from a previous open, it shows instantly.
function CartSidebar() {
const isOpen = useUIStore((state) => state.sidebarOpen);
const { data: cartItems } = useQuery({
queryKey: ['cart'],
queryFn: fetchCart,
enabled: isOpen, // only fetch when sidebar is open
});
if (!isOpen) return null;
return (
<div>
{cartItems?.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</div>
);
}
The enabled option here is a small but powerful pattern. The request only runs when the sidebar is visible, saving a network call if the user never opens the cart. This is something a global store would force you to manage manually with conditional dispatches.
Now, what about mutations? When the user adds an item to the cart, you use React Query’s useMutation. After a successful mutation, you invalidate the ['cart'] query so the sidebar gets fresh data. But what if you also want to update a client-side indicator, like a badge showing the item count? You could read it from React Query’s cache, but sometimes you want that count to update optimistically before the server responds. In that case, you can update a Zustand store as well.
const addItemMutation = useMutation({
mutationFn: addToCart,
onMutate: async (newItem) => {
// Optimistic update in Zustand
useCartCountStore.getState().incrementCount();
},
onSettled: () => {
// After mutation, refetch to sync
queryClient.invalidateQueries(['cart']);
},
});
This pattern gives you immediate feedback while keeping the source of truth on the server. If the mutation fails, you can roll back the Zustand count using onError.
A common question I hear is: “Should I put the user’s authentication token in Zustand or React Query?” The token itself is client state — it is stored in a cookie or localStorage, and you need it for every API call. So keep it in Zustand. But the user’s profile data (name, email, avatar) is server state. Fetch it with React Query. This separation makes your authentication logic much simpler to test.
Think about forms. A form with multiple fields — those field values are client state while the user is typing. Submitting the form is a mutation. Why would you put form values into React Query? You would not. Use Zustand or even local useState for the form fields, and only hand off the final data to React Query’s mutation when the user clicks submit.
Have you ever had to refresh the page because the data looked wrong after a state update? That is a sign you were mixing responsibilities. With Zustand and React Query, each piece of state lives where it belongs. Your components become predictable. Testing becomes straightforward because you can mock either layer independently.
Another scenario: real-time updates. WebSockets push new data to the client. You can use React Query’s queryClient.setQueryData to update the cache without a full refetch. Meanwhile, Zustand remains blissfully unaware of anything server-related. This keeps your client logic pristine.
I have used this combination in a SaaS dashboard with dozens of API endpoints and hundreds of UI toggles. The codebase stayed lean. New developers could jump in without learning a complicated state management paradigm. We removed entire layers of Redux slices and selectors. Bundle size dropped. Performance improved because React Query caches intelligently and Zustand only triggers updates on actual state changes.
If you are still using a single global store for everything, I encourage you to experiment. Start small. Extract one UI toggle into a Zustand store. Move one list of data into React Query. See how your mental model shifts. You will wonder why you ever kept them together.
And when you do, come back and tell me about your experience. Let me know if this approach worked for you. If you found this article helpful, please like, share, and leave a comment below. I want to hear your stories — especially the messy ones. Because that is where the best lessons live.
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