Skip to main content

Command Palette

Search for a command to run...

Modern React State Management: Zustand for Client State and TanStack Query for Server State

Published
11 min read

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

  1. Keep stores focused and modular - separate concerns into different stores

  2. Use computed state sparingly - calculate derived state in components when possible

  3. Implement persistence carefully - only persist essential user preferences

  4. Use middleware for cross-cutting concerns - logging, debugging, etc.

TanStack Query Best Practices

  1. Structure query keys consistently - use arrays with hierarchical structure

  2. Implement proper error boundaries - handle network failures gracefully

  3. Use optimistic updates judiciously - only for operations likely to succeed

  4. 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.