I remember the exact moment I realized my state management was broken. I was working on a dashboard that showed charts, user lists, and real-time notifications. The Redux store had become a dumping ground for everything—API responses, UI toggles, form inputs, and cached data. Every time someone added a new feature, the store grew bigger. Debugging became a nightmare. I was tired of writing action types, reducers, and selectors just to fetch a simple list of users. That’s why I started looking for something better.
The problem is that most developers treat all state the same way. They throw server data and client preferences into one big bucket. This leads to stale data, unnecessary re-renders, and a mess of logic that tries to both fetch from an API and manage a button toggle. The truth is, you need two different tools for two different jobs. That’s where Zustand and React Query come in.
Let me show you what I mean. Zustand is a tiny library that lets you create a store with a single function. It gives you hooks to read and write state, and that’s about it. No boilerplate, no context providers, no reducers. For client-side state—like whether a sidebar is open, or what theme the user selected—it’s perfect.
import { create } from 'zustand'
const useUIStore = create((set) => ({
sidebarOpen: true,
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
theme: 'light',
setTheme: (theme) => set({ theme })
}))
// In a component
function Sidebar() {
const sidebarOpen = useUIStore((state) => state.sidebarOpen)
const toggleSidebar = useUIStore((state) => state.toggleSidebar)
return <div>{sidebarOpen ? <button onClick={toggleSidebar}>Close</button> : null}</div>
}
This code is clean and easy to read. I don’t need to worry about async actions or caching. The store just holds current values. But what about data that comes from a server? That’s where React Query takes over.
React Query handles everything related to fetching and synchronizing remote data. You give it a query key and a function that returns a promise. It automatically caches the result, refetches when the window is refocused, and provides loading and error states. It even supports pagination, optimistic updates, and background refetch intervals.
import { useQuery } from '@tanstack/react-query'
import axios from 'axios'
function UserList() {
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => axios.get('/api/users').then(res => res.data)
})
if (isLoading) return <p>Loading...</p>
if (error) return <p>Error: {error.message}</p>
return (
<ul>
{data.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
)
}
Notice how I didn’t write any useEffect, useState, or manual caching logic. React Query does all that behind the scenes. It also deduplicates requests—if two components ask for the same data, only one fetch happens.
Now here’s the best part: you can make them talk to each other. For example, your Zustand store might hold a filter value. When the user changes that filter, you need to fetch new data from the server. You can pass the Zustand state directly into the React Query key, and React Query will automatically refetch when the key changes.
const useFilterStore = create((set) => ({
searchTerm: '',
setSearchTerm: (term) => set({ searchTerm: term })
}))
function SearchResults() {
const searchTerm = useFilterStore((state) => state.searchTerm)
const { data, isLoading } = useQuery({
queryKey: ['search', searchTerm],
queryFn: () => fetch(`/api/search?q=${searchTerm}`).then(res => res.json()),
enabled: searchTerm.length > 0
})
return <div>{isLoading ? 'Searching...' : data?.results.join(', ')}</div>
}
That enabled flag is great—it prevents the query from running when the search term is empty. This pattern keeps your code declarative. You don’t have to worry about manually triggering fetches or cleaning up subscriptions.
Have you ever found yourself manually caching API responses in a Redux reducer, only to realize the data is stale the next time you need it? I have. It’s painful. You end up writing custom middleware or adding timestamps to each cached item. React Query solves this with its built-in stale time and cache garbage collection.
const { data } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
staleTime: 5 * 60 * 1000, // data is fresh for 5 minutes
cacheTime: 10 * 60 * 1000 // data stays in cache for 10 minutes after unmount
})
When you combine Zustand and React Query, you get a clear separation: Zustand owns the UI state, React Query owns the server state. They interact through query keys or by reading the store inside the query function. This architecture scales beautifully because each piece has a single responsibility.
I personally use Zustand for things like a notification toast manager, sidebar visibility, and user preferences. React Query handles all API data: user profiles, orders, product lists, and any other asynchronous resource. The result is a codebase that I can understand even weeks later. I don’t have to dig through layers of reducers to see how a piece of state changes.
What about global data that needs to be shared across multiple components? That’s where many developers default to a single global store. But with React Query’s caching, you don’t need to store server data in a global store at all. The query client acts as a global cache, and you can access it anywhere using the same query key. This removes duplication and keeps your client store lean.
Let me give you a real example from a project I worked on. We had a settings page where the user could change their timezone. The timezone selection was client-side state (Zustand). Once saved, we needed to refetch the user’s schedule from the server using the new timezone. We used Zustand to update the timezone, and then invalidated the React Query cache for the schedule query.
const useSettingsStore = create((set) => ({
timezone: 'UTC',
setTimezone: (tz) => set({ timezone: tz })
}))
// Inside a save handler
const queryClient = useQueryClient()
const setTimezone = useSettingsStore((s) => s.setTimezone)
function handleTimezoneChange(newTz) {
setTimezone(newTz)
queryClient.invalidateQueries(['schedule'])
}
That’s it. The schedule query automatically refetches with the new timezone because we passed the timezone from Zustand as part of the query key. This pattern is simple, testable, and doesn’t require any complex middleware.
You might wonder: do I ever need to sync Zustand state with the server? Yes, sometimes. For example, if the user’s preferences should be persisted on the backend. In that case, you can use a mutation with React Query to save the preference, and then update the Zustand store optimistically.
const updateThemeMutation = useMutation({
mutationFn: (newTheme) => axios.post('/api/settings/theme', { theme: newTheme }),
onMutate: async (newTheme) => {
await queryClient.cancelQueries(['settings'])
const previousTheme = useUIStore.getState().theme
useUIStore.getState().setTheme(newTheme) // optimistic update
return { previousTheme }
},
onError: (err, newTheme, context) => {
useUIStore.getState().setTheme(context.previousTheme) // rollback
}
})
This gives the user instant feedback while the API call is in flight. If it fails, the store rolls back to the previous value. No manual state reconciliation needed.
I’ve seen teams try to build this functionality with plain React Context and useState. It works for small apps, but as soon as you need caching, deduplication, and background updates, you end up reinventing the wheel. Zustand and React Query together are the result of many smart people solving these exact problems. Don’t build your own data-fetching library.
If you’re currently using Redux and wondering if you should switch, consider this: you can start small. Introduce Zustand for a piece of UI state that doesn’t need async logic. Then use React Query for a single API endpoint. Once you see how much cleaner the code becomes, you’ll want to migrate the rest. You don’t have to do it all at once.
Now, here’s a question for you: how much time have you spent debugging stale state that was supposed to be in sync with the server? If the answer is more than a few hours, you owe it to yourself to try this pattern.
To wrap up, I encourage you to give this combination a try in your next React project. Keep your client state minimal and your server state smart. Your future self will thank you. If you found this useful, please like, share, and comment below—let me know if you have questions or if there’s another pattern you’d like me to cover.
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