As a React developer who has built everything from small dashboards to large enterprise applications, I’ve hit a wall time and again. That wall is state management. Specifically, the tangled mess that forms when server data and local interface logic are crammed into the same system. It starts small—a few useState hooks, maybe a Context. Then, API calls get mixed with UI toggles. Before you know it, changing a button’s color requires understanding how data is fetched from three different endpoints. This friction is why I stopped looking for a single, magical solution. Instead, I found clarity by using two focused tools together: Zustand for what happens in the browser, and React Query for what comes from the server. Let me show you how this pairing can clean up your code and scale with your ambition.
Think about the last React app you worked on. How much of its state was truly from a server, like a list of users or product details? Now, how much was just about how the app works, like a sidebar being open or a dark mode toggle? These are different beasts. Treating them the same way is like using a sledgehammer to crack a nut and a nutcracker to break a wall—it’s the wrong tool for the job. Server state is asynchronous, shared, and needs caching. Client state is synchronous, local, and often temporary. When you blur this line, you invite bugs and complexity.
This is where React Query enters the picture. It is not a general state manager. Its entire purpose is to handle anything that involves a remote data source. Give it a URL or a function that returns a promise, and React Query takes over. It will fetch the data, cache it, refetch it in the background when stale, and update your UI seamlessly. You don’t write useEffect hooks for loading states. You don’t manage your own cache. You simply declare what data you need. For instance, fetching a list of posts becomes straightforward.
import { useQuery } from '@tanstack/react-query'; // Version 4+
const fetchPosts = async () => {
const response = await fetch('/api/posts');
if (!response.ok) throw new Error('Network response failed');
return response.json();
};
function PostList() {
const { data: posts, isLoading, error } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
if (isLoading) return <div>Loading posts...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{posts.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
);
}
See how clean that is? The loading and error states are handled. The data is cached under the key ['posts']. If another component uses the same query, it gets the cached data instantly. But what about the state that isn’t from a server? What about the user’s current theme or a form’s draft values? This is client state, and for this, I use Zustand.
Zustand is minimal. It gives you a way to create a store without the ceremony of Redux or the provider nesting of Context. You define your state and the functions that update it in one place. It’s reactive, so your components update when the state changes. Here’s a store for a simple UI theme.
import { create } from 'zustand';
const useThemeStore = create((set) => ({
mode: 'light',
toggleMode: () => set((state) => ({
mode: state.mode === 'light' ? 'dark' : 'light'
})),
}));
// Using it in a component
function ThemeToggle() {
const { mode, toggleMode } = useThemeStore();
return (
<button onClick={toggleMode}>
Current theme: {mode}
</button>
);
}
The beauty is in the separation. React Query manages the posts from the API. Zustand manages the visual theme. Each library does one thing well. But how do they work together in a real component? They don’t fight; they coexist. You use hooks from both in the same component. The local state from Zustand and the server state from React Query are independent. This separation is the core of a scalable architecture.
Let’s build a practical example: a blog dashboard. We need to show a list of posts from a server (React Query) and let the user select one to edit in a sidebar (Zustand). The selected post ID is client state—it’s just a piece of information about what the user is doing right now.
import { useQuery } from '@tanstack/react-query';
import usePostStore from './stores/postStore'; // A Zustand store
// Zustand store definition (in postStore.js)
// import { create } from 'zustand';
// const usePostStore = create((set) => ({
// selectedPostId: null,
// setSelectedPostId: (id) => set({ selectedPostId: id }),
// }));
function BlogDashboard() {
// Server state: Fetch all posts
const { data: posts, isLoading } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
// Client state: Which post is selected?
const selectedPostId = usePostStore((state) => state.selectedPostId);
const setSelectedPostId = usePostStore((state) => state.setSelectedPostId);
if (isLoading) return <div>Loading...</div>;
return (
<div className="dashboard">
<div className="post-list">
{posts.map((post) => (
<div
key={post.id}
onClick={() => setSelectedPostId(post.id)}
className={selectedPostId === post.id ? 'selected' : ''}
>
{post.title}
</div>
))}
</div>
<Sidebar postId={selectedPostId} />
</div>
);
}
In this component, selectedPostId is held in Zustand. It’s a simple string or number. The list of posts is managed by React Query. When you click a post, you update the client state. The sidebar component can then use that postId to fetch more details, perhaps with another React Query hook. Notice there’s no mixing. The server data is fresh and cached. The UI state is snappy and local.
But what happens when you need to update server data based on a client action? For example, what if toggling a “favorite” button should send a PATCH request to an API? This is where the integration shines. You let React Query handle the mutation (the update), and Zustand might hold a temporary optimistic UI state. React Query has a useMutation hook for this.
import { useMutation, useQueryClient } from '@tanstack/react-query';
import useUiStore from './stores/uiStore'; // A Zustand store for UI feedback
function FavoriteButton({ postId }) {
const queryClient = useQueryClient();
const setNotification = useUiStore((state) => state.setNotification); // Client state for UI messages
const mutation = useMutation({
mutationFn: (newFavoriteStatus) =>
fetch(`/api/posts/${postId}/favorite`, {
method: 'PATCH',
body: JSON.stringify({ favorite: newFavoriteStatus }),
}),
onSuccess: () => {
// Invalidate the 'posts' query to refetch and update the list
queryClient.invalidateQueries({ queryKey: ['posts'] });
setNotification('Post favorited!'); // Update client UI state
},
onError: (error) => {
setNotification(`Error: ${error.message}`);
},
});
const handleClick = () => {
mutation.mutate(true);
};
return (
<button onClick={handleClick} disabled={mutation.isLoading}>
{mutation.isLoading ? 'Saving...' : 'Favorite this Post'}
</button>
);
}
Here, React Query’s useMutation handles the server call. Upon success, it triggers a refetch of the posts list to keep everything in sync. Simultaneously, I use a Zustand store (useUiStore) to show a temporary notification to the user. This is a clean handshake between server and client state. Each library manages its domain without overstepping.
Why does this approach scale so well? In a large application, teams can work on features without stepping on each other’s toes. The data fetching layer, governed by React Query, becomes a consistent protocol. All API interactions use the same patterns for caching and updates. The client state, managed by Zustand, is modular. You can have many small stores for different features—a store for user preferences, another for a shopping cart, another for modal states. They are simple to create and test. Have you ever had to trace a bug through a giant Redux reducer? With this split, the surface area for errors is smaller.
Performance is another win. React Query prevents unnecessary network requests by caching data aggressively. Zustand stores are lightweight and avoid re-renders by allowing components to subscribe only to the state slices they need. This combination keeps your app fast and responsive. It also makes your code easier to reason about. When you see a Zustand store, you know it’s about the client. When you see a React Query hook, you know it’s about the server.
I remember refactoring a project that used a single Context for everything. The component tree was bloated, and performance suffered. By introducing this separation, we cut the bundle size and made the code more readable. New developers could onboard faster because the patterns were clear. Server state logic was centralized with React Query, and client state was co-located with features using Zustand.
So, where should you start? Begin by installing both libraries. For React Query, set up a QueryClientProvider at the root of your app. For Zustand, create your first store for a piece of client state you currently have in Context or useState. Then, take one API call and move it to a useQuery hook. You’ll immediately feel the difference. The boilerplate melts away. Your components become more declarative.
What might surprise you is how little code you need to write. Both libraries are designed for simplicity. They get out of your way. This isn’t about adding complexity; it’s about reducing it by using specialized tools. Your application logic becomes a map with clear borders.
In conclusion, the journey from tangled state to clear architecture is about recognizing the natural divide in your data. By letting React Query rule the server and Zustand rule the client, you build a foundation that is robust, maintainable, and ready to grow. I’ve seen this transform projects, and I’m confident it can do the same for yours. If you found this perspective helpful, if it clarified a problem you’ve faced, please share this article with your team or fellow developers. Drop a comment below with your experiences or questions—I’d love to hear how you manage state in your React applications. Let’s build better software, together.
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