Modern React State Management: Zustand for Client State and TanStack Query for Server State
State management in React applications has evolved significantly, moving from monolithic solutions to specialized tools that handle different types of state more effectively. The combination of Zustand for global client-side state and TanStack Query for server-side state management represents a powerful, modern approach that simplifies complex applications while maintaining excellent performance and developer experience.
Understanding Client State vs. Server State
Before diving into implementation details, it's crucial to understand the fundamental difference between these two types of state, as they have different characteristics and require different management strategies.
Client State: Local Application Data
Client state refers to data that is specific to the client-side and is not dependent on server data. This includes:
UI state (modal visibility, theme preferences, sidebar collapse status)
Form inputs and validation states
User preferences and settings
Navigation state and routing information
Temporary application state
Client state is synchronous, predictable, and owned by the UI. You have full control over when and how it changes.
Server State: Remote Data with Unique Challenges
Server state refers to data that is fetched from an external server or API. This data has unique characteristics:
Asynchronous by nature - requires handling loading, success, and error states
Potentially stale - data can become outdated and needs refreshing
Shared ownership - other users or processes can modify the same data
Caching complexity - requires sophisticated strategies for performance
The key insight is that server state and client state are fundamentally different and should be managed by specialized tools.
Zustand: Elegant Global Client State Management
Zustand is a small, fast, and scalable state-management solution using simplified flux principles. It provides a comfy API based on hooks and isn't opinionated, making it perfect for managing client-side state.
Creating and Managing Stores with Zustand
Here's how to create a comprehensive client state management system:
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// UI State Store
const useUIStore = create((set, get) => ({
// Theme management
theme: 'light',
toggleTheme: () => set(state => ({
theme: state.theme === 'light' ? 'dark' : 'light'
})),
// Sidebar state
sidebarOpen: true,
toggleSidebar: () => set(state => ({
sidebarOpen: !state.sidebarOpen
})),
// Modal state
activeModal: null,
openModal: (modalId) => set({ activeModal: modalId }),
closeModal: () => set({ activeModal: null }),
// Loading state
globalLoading: false,
setGlobalLoading: (loading) => set({ globalLoading: loading }),
}));
// User Preferences Store with Persistence
const usePreferencesStore = create(
persist(
(set, get) => ({
language: 'en',
notifications: {
email: true,
push: true,
desktop: false
},
dashboard: {
layout: 'grid',
widgets: ['analytics', 'tasks', 'calendar']
},
// Actions
updateLanguage: (lang) => set({ language: lang }),
updateNotifications: (updates) => set(state => ({
notifications: { ...state.notifications, ...updates }
})),
updateDashboard: (updates) => set(state => ({
dashboard: { ...state.dashboard, ...updates }
})),
}),
{
name: 'user-preferences', // localStorage key
}
)
);
Modular Store Architecture
Zustand's modular approach makes complex global state scenarios more manageable. For large applications, separate stores by domain:
// Authentication Store
const useAuthStore = create((set) => ({
user: null,
isAuthenticated: false,
permissions: [],
login: (userData) => set({
user: userData,
isAuthenticated: true,
permissions: userData.permissions || []
}),
logout: () => set({
user: null,
isAuthenticated: false,
permissions: []
}),
updateProfile: (updates) => set(state => ({
user: { ...state.user, ...updates }
})),
}));
// Shopping Cart Store (E-commerce Example)
const useCartStore = create((set, get) => ({
items: [],
// Computed state function
getTotal: () => {
const { items } = get();
return items.reduce((total, item) => total + (item.price * item.quantity), 0);
},
addToCart: (product) => set(state => {
const existingItem = state.items.find(item => item.id === product.id);
if (existingItem) {
return {
items: state.items.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
)
};
}
return { items: [...state.items, { ...product, quantity: 1 }] };
}),
removeFromCart: (productId) => set(state => ({
items: state.items.filter(item => item.id !== productId)
})),
updateQuantity: (productId, quantity) => set(state => ({
items: state.items.map(item =>
item.id === productId ? { ...item, quantity } : item
)
})),
clearCart: () => set({ items: [] }),
}));
Using Zustand in Components
import { useUIStore, useCartStore } from './stores';
function Header() {
const { theme, toggleTheme, sidebarOpen, toggleSidebar } = useUIStore();
const { items, getTotal } = useCartStore();
return (
<header className={`header ${theme}`}>
<button onClick={toggleSidebar}>
{sidebarOpen ? 'Close' : 'Open'} Menu
</button>
<button onClick={toggleTheme}>
Switch to {theme === 'light' ? 'Dark' : 'Light'} Mode
</button>
<div className="cart-info">
Cart: {items.length} items (${getTotal().toFixed(2)})
</div>
</header>
);
}
TanStack Query: Powerful Server State Management
TanStack Query is hands down one of the best libraries for managing server state. It works amazingly well out-of-the-box, with zero-config, and can be customized to your liking.
Basic Setup and Data Fetching
import {
QueryClient,
QueryClientProvider,
useQuery
} from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
// Create query client with configuration
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
retry: 3,
refetchOnWindowFocus: false,
},
},
});
// App setup
function App() {
return (
<QueryClientProvider client={queryClient}>
<MainApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Advanced Query Patterns
import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// API functions
const api = {
fetchPosts: async (page = 1) => {
const response = await fetch(`/api/posts?page=${page}`);
return response.json();
},
fetchPost: async (id) => {
const response = await fetch(`/api/posts/${id}`);
return response.json();
},
createPost: async (postData) => {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(postData),
});
return response.json();
},
updatePost: async ({ id, ...data }) => {
const response = await fetch(`/api/posts/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return response.json();
},
};
// Posts List Component
function PostsList() {
const {
data: posts,
isLoading,
error,
refetch
} = useQuery({
queryKey: ['posts'],
queryFn: () => api.fetchPosts(),
staleTime: 5 * 60 * 1000, // Fresh for 5 minutes
});
if (isLoading) return <div>Loading posts...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<button onClick={() => refetch()}>Refresh Posts</button>
{posts?.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}
// Infinite Scroll Example
function InfinitePostsList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
error
} = useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: ({ pageParam = 1 }) => api.fetchPosts(pageParam),
getNextPageParam: (lastPage, allPages) => {
return lastPage.hasMore ? allPages.length + 1 : undefined;
},
});
const posts = data?.pages.flatMap(page => page.posts) ?? [];
return (
<div>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
{hasNextPage && (
<button
onClick={fetchNextPage}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading more...' : 'Load More'}
</button>
)}
</div>
);
}
Optimistic UI and Advanced Caching with TanStack Query
Optimistic UI allows CRUD operations to be rendered immediately on the UI before the request roundtrip has completed. This creates a snappy user experience by showing changes instantly.
Implementing Optimistic Updates
function useOptimisticPost() {
const queryClient = useQueryClient();
// Create post with optimistic update
const createPost = useMutation({
mutationFn: api.createPost,
// Optimistic update
onMutate: async (newPost) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['posts'] });
// Snapshot previous value
const previousPosts = queryClient.getQueryData(['posts']);
// Optimistically update
queryClient.setQueryData(['posts'], old => [
{
id: Date.now(), // Temporary ID
...newPost,
isOptimistic: true
},
...old
]);
return { previousPosts };
},
// On error, rollback
onError: (err, newPost, context) => {
queryClient.setQueryData(['posts'], context.previousPosts);
},
// Always refetch after error or success
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
// Update post with optimistic update
const updatePost = useMutation({
mutationFn: api.updatePost,
onMutate: async (updatedPost) => {
await queryClient.cancelQueries({ queryKey: ['posts', updatedPost.id] });
const previousPost = queryClient.getQueryData(['posts', updatedPost.id]);
// Update individual post cache
queryClient.setQueryData(['posts', updatedPost.id], updatedPost);
// Update posts list cache
queryClient.setQueryData(['posts'], old =>
old?.map(post =>
post.id === updatedPost.id ? updatedPost : post
)
);
return { previousPost };
},
onError: (err, updatedPost, context) => {
queryClient.setQueryData(['posts', updatedPost.id], context.previousPost);
},
onSettled: (data, error, updatedPost) => {
queryClient.invalidateQueries({ queryKey: ['posts', updatedPost.id] });
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
return { createPost, updatePost };
}
// Usage in component
function CreatePostForm() {
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const { createPost } = useOptimisticPost();
const handleSubmit = async (e) => {
e.preventDefault();
try {
await createPost.mutateAsync({ title, content });
setTitle('');
setContent('');
} catch (error) {
console.error('Failed to create post:', error);
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Post title"
required
/>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Post content"
required
/>
<button
type="submit"
disabled={createPost.isLoading}
>
{createPost.isLoading ? 'Creating...' : 'Create Post'}
</button>
</form>
);
}
Advanced Caching Strategies
// Background refetching and synchronization
function usePostsWithBackground() {
return useQuery({
queryKey: ['posts'],
queryFn: api.fetchPosts,
// Refetch every 30 seconds in background
refetchInterval: 30 * 1000,
// Refetch when user returns to tab
refetchOnWindowFocus: true,
// Keep previous data while refetching
keepPreviousData: true,
// Custom stale time
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
// Dependent queries
function usePostWithComments(postId) {
const postQuery = useQuery({
queryKey: ['posts', postId],
queryFn: () => api.fetchPost(postId),
});
const commentsQuery = useQuery({
queryKey: ['posts', postId, 'comments'],
queryFn: () => api.fetchComments(postId),
enabled: !!postQuery.data, // Only run if post data exists
});
return {
post: postQuery.data,
comments: commentsQuery.data,
isLoading: postQuery.isLoading || commentsQuery.isLoading,
error: postQuery.error || commentsQuery.error,
};
}
Integration Architecture: Combining Zustand and TanStack Query
The combination of Zustand and TanStack Query creates a powerful, scalable solution for managing server-side and client-side states. Here's how they work together:
State Manager Integration Architecture
┌─────────────────────────────────────────────────────────────┐
│ React Application │
├─────────────────────────────────────────────────────────────┤
│ Components │
│ ┌─────────────────────┐ ┌─────────────────────────────┐ │
│ │ Client State │ │ Server State │ │
│ │ (Zustand) │ │ (TanStack Query) │ │
│ │ │ │ │ │
│ │ • UI State │ │ • API Data │ │
│ │ • User Preferences │ │ • Caching │ │
│ │ • Form State │ │ • Background Updates │ │
│ │ • Navigation │ │ • Optimistic Updates │ │
│ └─────────────────────┘ └─────────────────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ Integration Layer │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Custom Hooks & Components │ │
│ │ │ │
│ │ • Loading states sync between stores │ │
│ │ • Error handling coordination │ │
│ │ • Authentication state management │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Practical Integration Example
// Combined store for loading states
const useAppStore = create((set, get) => ({
// Global loading state
isGlobalLoading: false,
setGlobalLoading: (loading) => set({ isGlobalLoading: loading }),
// Error handling
errors: [],
addError: (error) => set(state => ({
errors: [...state.errors, { id: Date.now(), ...error }]
})),
removeError: (id) => set(state => ({
errors: state.errors.filter(error => error.id !== id)
})),
clearErrors: () => set({ errors: [] }),
// Success notifications
notifications: [],
addNotification: (notification) => set(state => ({
notifications: [...state.notifications, { id: Date.now(), ...notification }]
})),
removeNotification: (id) => set(state => ({
notifications: state.notifications.filter(n => n.id !== id)
})),
}));
// Custom hook that integrates both stores
function useIntegratedDataFetching(queryKey, queryFn, options = {}) {
const { setGlobalLoading, addError, addNotification } = useAppStore();
return useQuery({
queryKey,
queryFn,
onSuccess: (data) => {
setGlobalLoading(false);
if (options.successMessage) {
addNotification({
type: 'success',
message: options.successMessage,
});
}
},
onError: (error) => {
setGlobalLoading(false);
addError({
type: 'api',
message: error.message,
query: queryKey,
});
},
onSettled: () => {
setGlobalLoading(false);
},
...options,
});
}
// Usage in components
function PostsPage() {
const { data: posts, isLoading } = useIntegratedDataFetching(
['posts'],
api.fetchPosts,
{ successMessage: 'Posts loaded successfully!' }
);
const { theme, sidebarOpen } = useUIStore();
const { errors, notifications, removeError, removeNotification } = useAppStore();
return (
<div className={`posts-page ${theme}`}>
<Sidebar isOpen={sidebarOpen} />
{/* Error display */}
{errors.map(error => (
<ErrorBanner
key={error.id}
error={error}
onClose={() => removeError(error.id)}
/>
))}
{/* Success notifications */}
{notifications.map(notification => (
<Notification
key={notification.id}
notification={notification}
onClose={() => removeNotification(notification.id)}
/>
))}
{/* Main content */}
{isLoading ? (
<LoadingSpinner />
) : (
<PostsList posts={posts} />
)}
</div>
);
}
Best Practices and Performance Considerations
Zustand Best Practices
Keep stores focused and modular - separate concerns into different stores
Use computed state sparingly - calculate derived state in components when possible
Implement persistence carefully - only persist essential user preferences
Use middleware for cross-cutting concerns - logging, debugging, etc.
TanStack Query Best Practices
Structure query keys consistently - use arrays with hierarchical structure
Implement proper error boundaries - handle network failures gracefully
Use optimistic updates judiciously - only for operations likely to succeed
Configure caching strategically - balance freshness with performance
Performance Tips
// Selective subscriptions in Zustand
const useTheme = () => useUIStore(state => state.theme);
const useToggleTheme = () => useUIStore(state => state.toggleTheme);
// Query prefetching
function usePostPreloading() {
const queryClient = useQueryClient();
const preloadPost = (postId) => {
queryClient.prefetchQuery({
queryKey: ['posts', postId],
queryFn: () => api.fetchPost(postId),
staleTime: 10 * 1000, // 10 seconds
});
};
return { preloadPost };
}
Conclusion
The combination of Zustand for client-side state and TanStack Query for server-side state represents a modern, efficient approach to React state management. Zustand provides simple, unopinionated client state management without the boilerplate of traditional solutions, while TanStack Query excels in managing server state with intelligent caching and synchronization.
This architecture offers several key advantages:
Clear separation of concerns - each tool handles what it does best
Reduced complexity - less boilerplate than traditional Redux patterns
Better performance - efficient caching and selective re-renders
Enhanced developer experience - intuitive APIs and excellent debugging tools
Scalability - modular architecture that grows with your application
By understanding when to use each tool and how to integrate them effectively, you can build React applications that are both performant and maintainable, providing excellent user experiences while keeping your codebase clean and understandable.