I remember the first time I tried to build a React app that had both a complex UI and heavy server data. I used one state management library for everything. It was a mess. Loading states for API calls were scattered across components. UI toggles were fighting with cached data. I was constantly clearing local state when the server returned new results. That’s when I realized I needed two different tools for two different jobs.
Have you ever felt like you’re shoving round pegs into square holes when managing state?
Most developers start with React’s built-in useState and useReducer. For simple apps, that works fine. But when you add authentication, real-time updates, and dozens of UI preferences, things get heavy. You reach for a global state solution like Redux or MobX. Then you find yourself writing reducers just to track whether a button is loading. That’s where the boundary between server state and client state gets blurry.
Let me define those two things clearly. Server state is data that lives on a remote database. User profiles, product lists, order histories. This data is asynchronous. It can be stale. It needs caching, background refetching, and optimistic updates. Client state is everything else. Whether a modal is open. Which tab is active. The current step in a multi‑step form. That data is synchronous. It exists only in the browser’s memory and doesn’t need a server round trip.
Imagine you have a dashboard that shows sales reports. The report data comes from an API. The filter options you selected (date range, region) are client state. If you store the filter selections inside the same cache as the report data, you’ll have a hard time invalidating one without breaking the other.
That’s why I started using React Query for server state and Zustand for client state. React Query handles the network layer beautifully. I don’t need to write useEffect for data fetching anymore. I just call useQuery and it manages loading, error, caching, and refetching for me. Here’s a minimal example:
import { useQuery } from '@tanstack/react-query'
function useSalesData() {
return useQuery({
queryKey: ['sales', { month: '2025-03' }],
queryFn: () => fetch('/api/sales').then(res => res.json()),
staleTime: 5 * 60 * 1000, // keep fresh for 5 minutes
})
}
That’s it. React Query will cache the result, refetch in the background when the data is too old, and automatically update the UI. I don’t have to think about canceling requests or cleaning up state. The library does it for me.
Now for client state. Zustand is tiny. The entire source code is less than 1KB compressed. No providers, no boilerplate. I create a store with a simple function and get a hook. Here’s an example for managing the active tab and a modal:
import { create } from 'zustand'
const useUIStore = create((set) => ({
activeTab: 'overview',
isModalOpen: false,
setActiveTab: (tab) => set({ activeTab: tab }),
toggleModal: () => set((state) => ({ isModalOpen: !state.isModalOpen })),
}))
Now I can use this store in any component. It’s reactive. It’s fast. It doesn’t need to be serialized or sent to a server. It’s pure client memory. That separation keeps my code predictable.
Why does this matter in production? Because when you mix server and client state, you get bugs that are hard to reproduce. For example, you open a modal that depends on some user preferences. Those preferences are fetched from the server. While the fetch is loading, the modal should maybe wait or show a skeleton. If you store both the preferences and the modal state in the same Redux slice, you’ll need to manually reset the modal when the preferences change. With Zustand and React Query, they don’t interfere.
Here’s a concrete integration pattern I use often. Imagine a settings page where the user can change their display name and toggle night mode. The display name comes from an API. The night mode toggle is a local UI preference.
function SettingsPage() {
const { data: user, isLoading } = useQuery({
queryKey: ['user'],
queryFn: () => fetch('/api/user').then(res => res.json()),
})
const { nightMode, toggleNightMode } = useUIStore()
if (isLoading) return <div>Loading your profile...</div>
return (
<div>
<h1>Welcome, {user.displayName}</h1>
<button onClick={toggleNightMode}>
{nightMode ? 'Disable' : 'Enable'} Night Mode
</button>
</div>
)
}
Notice how clear the responsibilities are. The server state (user) is handled by React Query. The client state (nightMode) is handled by Zustand. Neither touches the other’s data. If the server returns a new username, React Query updates the UI. If the user toggles night mode, only Zustand re-renders the component that uses it.
But there’s more to this integration. Sometimes you need the client state to trigger a server action. For instance, when a user submits a form, you might want to show a success toast (client state) and then invalidate a query so the server data refreshes. Here’s how I do it:
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useToastStore } from './stores/toastStore'
function useUpdateProfile() {
const queryClient = useQueryClient()
const addToast = useToastStore((state) => state.addToast)
return useMutation({
mutationFn: (newProfile) => fetch('/api/profile', { method: 'POST', body: JSON.stringify(newProfile) }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['user'] })
addToast('Profile updated successfully')
},
})
}
Here, the toast store (Zustand) is called inside the React Query mutation’s onSuccess callback. That’s the clean junction point. React Query handles the server interaction, and Zustand handles the UI feedback. They talk through a known interface, not through shared state.
What about when you need to read server data inside a Zustand store? That’s a trap. Don’t do it. Zustand stores should only hold synchronous client state. If you need to derive something from server data, use a React Query selector or a custom hook that combines both. For example:
function useCombinedDashboard() {
const { data: sales } = useQuery({ queryKey: ['sales'], queryFn: fetchSales })
const { activePeriod } = useUIStore()
if (!sales) return { summary: null }
const filteredSales = sales.filter(s => s.period === activePeriod)
return { summary: filteredSales }
}
Now the hook returns a computed value. The store is still pure. The query is still isolated. The component that calls useCombinedDashboard doesn’t care about the internals.
One common mistake I made early on was storing the entire query response in Zustand “for convenience.” That broke React Query’s caching mechanism. When I needed to refetch, I had to manually update the store. Avoid that. Let React Query own the server data. Let Zustand own the UI switches.
Have you ever debugged a state bug that disappeared after a page refresh? That’s often a sign you’re mixing server and client state. React Query’s caching can cause stale data to persist if you copy it into another store. Keep them separate and the bugs will become obvious.
Another benefit: testing becomes easier. Zustand stores are plain functions. I can test them in isolation. React Query’s queries are also testable with mock service workers. When the two are separate, I write unit tests for UI logic without mocking network calls, and I write integration tests for data fetching without worrying about UI state polution.
In my current project, we have a complex checkout flow. The product catalog comes from React Query. The cart contents (which items, quantities) are client state because we don’t save the cart until the user submits. We used Zustand for the cart. When the user completes the order, we invalidate the product queries to reflect reduced stock. The cart store stays unchanged until the next visit. Clean.
Now let me talk about performance. Zustand is fast because it uses subscriptions. React Query is fast because it deduplicates requests and caches aggressively. Combined, you avoid unnecessary re-renders. The UI store only updates when a user clicks a toggle. The server data only updates when the cache invalidates. No wasted renders.
If you’re worried about boilerplate, relax. Zustand doesn’t need a provider. React Query needs a QueryClientProvider at the root, but that’s a one‑time setup. Here’s the index.js:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { create } from 'zustand'
const queryClient = new QueryClient()
function App() {
return (
<QueryClientProvider client={queryClient}>
<Main />
</QueryClientProvider>
)
}
That’s it. Every component inside
I’ve seen teams argue about whether to put everything in Redux or everything in a single Context. Both approaches fall apart at scale. The Zustand + React Query pattern is not a theoretical best practice; it’s a pragmatic solution that thousands of developers use in production. The documentation of both libraries explicitly advises this separation.
To summarize, treat your state like you treat your kitchen. You don’t store the refrigerator inside the spice rack. React Query is your refrigerator – it keeps server data fresh. Zustand is your spice rack – it holds small, fast‑changing pieces. Keep them separate and your code will stay organized, debuggable, and scalable.
Now I want to hear from you. Have you tried this combination? Did it make your life easier? Maybe you’re still struggling with a state management soup. Let me know in the comments. Share this article with a teammate who’s debating between Redux and something lighter. And if you found value, hit the like button. Your engagement helps me write more practical guides like this one.
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