I remember the exact moment I realized my React app was suffering from an identity crisis. The state management library I had chosen for everything—local UI toggles, user preferences, and API responses—had turned into a bloated monster. Every time I added a new feature, I had to manually invalidate caches, track down stale data, and debug re-renders caused by state that didn’t need to be global. That’s when I started looking for a cleaner way to separate what belongs on the client from what belongs to the server.
Think about the last time you built a dashboard. You had search filters, dark mode toggle, a sidebar open/close flag—all pure UI state. And then you had the list of users fetched from an API, with loading spinners, retries, and background updates. Mixing these two categories in a single store is like storing your groceries in the same drawer as your socks. It works, but the drawer gets messy fast.
Zustand solves the local UI state problem with almost no ceremony. You define a store as a plain object, call create, and that’s it. No providers, no context wrapping, no reducers. React Query, on the other hand, is designed from the ground up to handle server state—caching, refetching, optimistic updates, and stale-while-revalidate logic. When you pair them, you get a lightweight, predictable architecture where each library does what it does best.
Let me show you how simple this separation looks in practice.
The Zustand store for client-only state
import { create } from 'zustand'
const useAppStore = create((set) => ({
sidebarOpen: false,
selectedFilter: 'all',
darkMode: false,
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setFilter: (filter) => set({ selectedFilter: filter }),
toggleDarkMode: () => set((state) => ({ darkMode: !state.darkMode })),
}))
That’s it. No actions, no reducers, no TypeScript gymnastics needed. This store lives entirely on the client. It doesn’t care about the server. It doesn’t fetch anything. It just holds the current state of the UI.
Now, what about the data that comes from an API? React Query takes over.
React Query for server state
import { useQuery } from '@tanstack/react-query'
function fetchUsers() {
return fetch('/api/users').then((res) => res.json())
}
function UsersList() {
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
})
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error loading users</div>
return (
<ul>
{data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
React Query handles caching, deduplication, background refetching, and stale data detection automatically. You don’t need to write any cache invalidation logic—it’s built in. This is the kind of state management that belongs to the server side of your app.
But here’s where the magic happens: how do you make these two libraries talk to each other? You don’t. They stay separate, but they can read each other’s outputs easily.
For example, let’s say you want to refetch the users list when a filter changes. The filter lives in Zustand. The query lives in React Query. You can pass the Zustand filter value as part of the query key.
function FilteredUsersList() {
const filter = useAppStore((state) => state.selectedFilter)
const { data } = useQuery({
queryKey: ['users', filter],
queryFn: () => fetchUsersByFilter(filter),
})
return ... // render data
}
When the user changes the filter via Zustand, the component re-renders, React Query sees the new query key, and automatically fetches the correct data. No manual triggers, no store updates for data. The separation is clean.
This pattern becomes incredibly powerful when you have multiple filters, pagination, or sorting. Zustand keeps the UI state, React Query keeps the server state, and the two never collide.
I often ask myself: why did we ever think that a single global store should hold everything? The answer is historical convenience. Before React Query, we had to manage loading states, caching, and refetching ourselves. So we shoved everything into Redux or MobX. But now that tools like React Query exist, we can be smarter.
Another common situation is when you need to trigger a server mutation after a local UI change. For instance, toggling a favorite button. The optimistic update should be handled by React Query’s mutation hooks, not by Zustand. Here’s how that looks.
import { useMutation, useQueryClient } from '@tanstack/react-query'
function FavoriteButton({ userId }) {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: (id) => fetch(`/api/users/${id}/favorite`, { method: 'POST' }),
onMutate: async (id) => {
// Cancel outgoing queries for users
await queryClient.cancelQueries({ queryKey: ['users'] })
// Snapshot previous value
const previousUsers = queryClient.getQueryData(['users'])
// Optimistically update the cache
queryClient.setQueryData(['users'], (old) =>
old.map((u) => (u.id === id ? { ...u, isFavorited: !u.isFavorited } : u))
)
return { previousUsers }
},
onError: (err, id, context) => {
// Roll back on error
queryClient.setQueryData(['users'], context.previousUsers)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
return <button onClick={() => mutation.mutate(userId)}>Toggle Favorite</button>
}
Notice that Zustand is completely absent here. The local UI (the button state) is driven by the server state via the query cache. This is a very good thing. Your UI reacts to the cache, not the other way around.
Have you ever found yourself writing a reducer just to track whether a modal is open? That’s exactly the kind of thing that belongs in a tiny Zustand store. And have you ever tried to sync two different useEffect hooks to keep data fresh? That’s what React Query does for you.
Why this separation matters at scale
When your application grows, the biggest source of bugs is stale data. If you put server data in Zustand or Redux, you have to manually manage timers, refetch triggers, and cache expiry. React Query handles all of that silently. And your Zustand store stays small, predictable, and easy to debug.
I’ve seen teams double their codebase size by merging these concerns. The result is a tangled web of actions, thunks, and selectors that no one fully understands. By keeping Zustand for pure client state and React Query for server state, you reduce cognitive load and make onboarding new developers much easier.
Let me give you a personal example. I was building a product management dashboard that had a complex filter panel (date range, category, status) and a real-time data table. The filters were stored in Zustand. The table data came from a React Query hook. Every time a filter changed, the query key changed, and React Query fetched fresh data. Not a single line of cache invalidation code. It felt like cheating.
The only challenge is deciding where the boundary lies. A good rule of thumb is: if the data would disappear when you close the browser tab, it’s client state. If it comes from an API or a database, it’s server state. Forms, toasts, modals, animation preferences—those are client. User profiles, product lists, orders, settings persisted on the backend—those are server.
Would you ever want to re-generate a toast notification after a page refresh? Probably not. So keep it in Zustand. Would you want to see the latest user list after re-opening the browser after an hour? Yes. So keep it in React Query.
Conclusion
By now you should see that Zustand and React Query are not competitors. They are complementary tools that solve different parts of the same puzzle. Zustand gives you a lightweight, flexible box for all the ephemeral, interactive state that makes your UI feel responsive. React Query gives you a robust, declarative engine for all the data that lives outside your browser.
If you’re tired of debugging stale caches and bloated stores, I strongly encourage you to try this separation. Start small: move your sidebar toggle and filter values into a Zustand store, and let React Query handle your API calls. You’ll notice the difference in code clarity and performance almost immediately.
What has been your experience with state management in React? Drop your thoughts in the comments below. If this article helped you think differently about architecture, please like it and share it with your team. And if you have your own tips for separating client and server state, I would love to hear them.
Now go clean up that drawer. Your code will thank you.
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