Skip to main content

Global State vs Server State: Separating Concerns

The biggest mistake junior developers make is using Zustand or Jotai for data that should live on the server. Global state tools are for client-side state (modals, filters, theme); server state tools are for API responses (users, posts, products). Mixing them creates stale data, synchronization bugs, and hard-to-debug race conditions.

In 2024, I debugged a bug where a user's profile updated on the backend, but the Zustand store never re-fetched the new data. The data sat in global state for hours. Moving to React Query fixed it instantly.

What Is Global State?

Global state is client-only data that does not live on a server: UI state, user preferences, form drafts, modal visibility. Example:

// Global state: appropriate for Zustand
const useUIStore = create((set) => ({
darkMode: false,
toggleDarkMode: () => set((state) => ({ darkMode: !state.darkMode })),

sidebarOpen: true,
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),

selectedFilter: 'all',
setSelectedFilter: (filter) => set({ selectedFilter: filter }),
}));

This state is not synced to any server. It is local to the browser. If the user refreshes, you can restore it from localStorage, but there is no backend source of truth.

What Is Server State?

Server state is API data that lives on a backend: user profiles, posts, products, comments. This data is the source of truth. The client is a copy that can become stale. Example:

// BAD: Storing server state in Zustand
const useBadStore = create((set) => ({
users: [],
fetchUsers: async () => {
const response = await fetch('/api/users');
const data = await response.json();
set({ users: data });
// Problem: data can become stale; no automatic revalidation
},
}));

When does the data refresh? Only when fetchUsers() is called. If another user updates a user on the backend, this client never knows. This causes UI inconsistency.

The Right Approach: React Query for Server State

Use React Query (TanStack Query) to manage server state. React Query handles caching, revalidation, and synchronization:

import { useQuery } from '@tanstack/react-query';

export function UserList() {
const { data: users, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: async () => {
const response = await fetch('/api/users');
return response.json();
},
});

if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}

React Query caches the data, automatically revalidates it on window focus, handles background refetches, and keeps the UI in sync. You do not manually manage loading/error state.

Hybrid Approach: Zustand + React Query

Use Zustand for global state and React Query for server state in the same app:

import { useQuery } from '@tanstack/react-query';
import { create } from 'zustand';

// Global state: UI
const useUIStore = create((set) => ({
selectedUserId: null,
setSelectedUserId: (id) => set({ selectedUserId: id }),
}));

// Server state: Data
export function UserDetail() {
const userId = useUIStore((state) => state.selectedUserId);

const { data: user } = useQuery({
queryKey: ['users', userId],
queryFn: async () => {
const response = await fetch(`/api/users/${userId}`);
return response.json();
},
enabled: !!userId, // Only fetch if userId is set
});

return <div>{user?.name}</div>;
}

Zustand holds selectedUserId (UI state). React Query fetches and caches the user data (server state) for the selected ID.

Decision Matrix: Global vs Server State

DataSource of TruthUpdate PatternTool
Dark mode toggleClientUser clicks buttonZustand/Jotai
List of postsServerBackend polls or pushReact Query
Form draftClientUser typesZustand/Jotai
Current user profileServerBackend (user logs in or updates)React Query
Sidebar open/closedClientUser clicks sidebar buttonZustand/Jotai
Comments on a postServerBackend (comments added by others)React Query
Sort order in a tableClient (can be reset)User clicks column headerZustand/Jotai
Search resultsServerBackend search APIReact Query

If the data originates on a server and can be edited by other users or processes, use React Query. If it is local-only and controlled by the current user, use Zustand or Jotai.

Key Takeaways

  • Global state (Zustand/Jotai) is for client-only data: UI state, preferences, form drafts.
  • Server state (React Query) is for API data: users, posts, any data with a backend source of truth.
  • Storing server state in global state causes stale data and synchronization bugs.
  • React Query handles caching, revalidation, and background updates automatically.
  • Use a hybrid: Zustand for UI, React Query for data. They work well together.

Frequently Asked Questions

Can I use Zustand for all state?

Technically yes, but you will duplicate caching logic and struggle with synchronization. For any API data, React Query is better.

Does React Query work with Zustand?

Yes. React Query and Zustand are complementary. Use them together: Zustand for UI, React Query for data.

What if I need to share server state across many components?

React Query caches by query key. Define a query key once (e.g., ['users']) and use it everywhere. React Query deduplicates requests automatically.

Is React Query a state management library?

Not in the traditional sense. It is a data-fetching and caching library. It manages server state, not global state. Combine it with Zustand or Context for global state.

Should I ever store API responses in Zustand?

Only if you are sure the data will not change server-side (immutable reference data) or if you are building a fully offline-first app. For typical CRUD data, use React Query.

Further Reading