Skip to main content
{/}codefunc
GuidesJuly 7, 2026 · 8 min read

React State Management in 2026: Server State, Client State, and When to Use What

A practical guide to modern React state management — TanStack Query for server state, Zustand for client state, and patterns that replaced Redux for most applications.

By codefunc

reactstate-managementtanstack-queryzustandreduxhooks

State management in React used to mean Redux for everything — actions, reducers, thunks, and boilerplate. In 2026, the ecosystem has matured. The key insight: server state (data from APIs) and client state (UI state like "is the sidebar open?") are different problems requiring different solutions.

Bottom line: Use TanStack Query (or SWR) for server state — it handles caching, background refetching, and loading states automatically. Use Zustand (or Jotai) for client state — it is simpler than Redux with less boilerplate. Use React Context for dependency injection, not state management. Reserve Redux for complex apps with established patterns.

The state management landscape

Library Best for Complexity
TanStack Query Server state (API data) Low-medium
SWR Server state (simpler API) Low
Zustand Client state (simple global) Low
Jotai Client state (atomic/derived) Low-medium
Redux Toolkit Complex client state, established codebases Medium-high
React Context Dependency injection, theming Low
useState/useReducer Component-local state Low

Server state vs client state

Server state

Data that lives on your backend and is fetched via API:

  • User profile from /api/users/me
  • List of posts from /api/posts
  • Search results from /api/search?q=...

Characteristics:

  • Owned by the server
  • Can become stale
  • Shared across components
  • Needs loading/error states
  • May need background refetching

Client state

Data that lives only in the browser:

  • Is the modal open?
  • Current selected tab
  • Form input values
  • Theme preference
  • Shopping cart (before checkout)

Characteristics:

  • Owned by the client
  • Synchronous access
  • May need persistence (localStorage)
  • No loading states needed

TanStack Query for server state

TanStack Query (formerly React Query) is the standard for server state management. It handles caching, deduplication, background updates, and pagination automatically.

Basic query

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

function UserProfile({ userId }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
  });

  if (isLoading) return <Spinner />;
  if (error) return <Error message={error.message} />;
  
  return <Profile user={data} />;
}

Query keys

Query keys identify cached data. Use arrays with identifiers:

// User by ID
queryKey: ['user', userId]

// Posts with filters
queryKey: ['posts', { status: 'published', page: 1 }]

// Nested resource
queryKey: ['user', userId, 'posts']

When any part of the key changes, the query refetches.

Mutations

For creating, updating, or deleting data:

import { useMutation, useQueryClient } from '@tanstack/react-query';

function CreatePost() {
  const queryClient = useQueryClient();
  
  const mutation = useMutation({
    mutationFn: (newPost) => 
      fetch('/api/posts', {
        method: 'POST',
        body: JSON.stringify(newPost),
      }).then(res => res.json()),
    onSuccess: () => {
      // Invalidate and refetch posts
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
  });

  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      mutation.mutate({ title: 'New Post', body: '...' });
    }}>
      <button disabled={mutation.isPending}>
        {mutation.isPending ? 'Creating...' : 'Create Post'}
      </button>
    </form>
  );
}

Optimistic updates

Update the UI before the server responds:

const mutation = useMutation({
  mutationFn: updateTodo,
  onMutate: async (newTodo) => {
    // Cancel outgoing refetches
    await queryClient.cancelQueries({ queryKey: ['todos'] });
    
    // Snapshot previous value
    const previousTodos = queryClient.getQueryData(['todos']);
    
    // Optimistically update
    queryClient.setQueryData(['todos'], (old) =>
      old.map(todo => todo.id === newTodo.id ? newTodo : todo)
    );
    
    return { previousTodos };
  },
  onError: (err, newTodo, context) => {
    // Rollback on error
    queryClient.setQueryData(['todos'], context.previousTodos);
  },
  onSettled: () => {
    // Always refetch after error or success
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});

Pagination

function Posts() {
  const [page, setPage] = useState(1);
  
  const { data, isPlaceholderData } = useQuery({
    queryKey: ['posts', page],
    queryFn: () => fetchPosts(page),
    placeholderData: keepPreviousData, // Keep showing old data while fetching
  });

  return (
    <>
      <PostList posts={data?.posts} />
      <button 
        onClick={() => setPage(p => p - 1)} 
        disabled={page === 1}
      >
        Previous
      </button>
      <button
        onClick={() => setPage(p => p + 1)}
        disabled={isPlaceholderData || !data?.hasMore}
      >
        Next
      </button>
    </>
  );
}

Infinite queries

const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
  queryKey: ['posts'],
  queryFn: ({ pageParam }) => fetchPosts(pageParam),
  initialPageParam: 1,
  getNextPageParam: (lastPage) => lastPage.nextCursor,
});

const allPosts = data?.pages.flatMap(page => page.posts) ?? [];

Zustand for client state

Zustand is a minimal state management library. No providers, no boilerplate, just stores.

Basic store

import { create } from 'zustand';

const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));

function Counter() {
  const { count, increment, decrement } = useStore();
  
  return (
    <div>
      <span>{count}</span>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </div>
  );
}

TypeScript

interface BearState {
  bears: number;
  increase: (by: number) => void;
}

const useBearStore = create<BearState>()((set) => ({
  bears: 0,
  increase: (by) => set((state) => ({ bears: state.bears + by })),
}));

Selecting state

Only re-render when selected state changes:

// Component re-renders on any state change
const state = useStore();

// Component only re-renders when count changes
const count = useStore((state) => state.count);

// Multiple selectors
const { count, increment } = useStore(
  (state) => ({ count: state.count, increment: state.increment }),
  shallow // Compare shallowly
);

Persist to localStorage

import { persist } from 'zustand/middleware';

const useStore = create(
  persist(
    (set) => ({
      theme: 'light',
      setTheme: (theme) => set({ theme }),
    }),
    {
      name: 'app-storage', // localStorage key
    }
  )
);

Async actions

const useStore = create((set) => ({
  user: null,
  loading: false,
  
  fetchUser: async (id) => {
    set({ loading: true });
    try {
      const user = await api.getUser(id);
      set({ user, loading: false });
    } catch (error) {
      set({ loading: false });
      throw error;
    }
  },
}));

React Context: when to use it

Context is for dependency injection, not state management:

// Good: Theme, locale, auth context
const ThemeContext = createContext<Theme>('light');

function App() {
  return (
    <ThemeContext.Provider value="dark">
      <Page />
    </ThemeContext.Provider>
  );
}

// Avoid: Frequently updating state
// Every consumer re-renders on every change
const CountContext = createContext({ count: 0, setCount: () => {} });

When Context is appropriate

  • Theme/locale that rarely changes
  • Auth state (current user)
  • Feature flags
  • Dependency injection (services, config)

When to avoid Context

  • Frequently updating values (use Zustand)
  • Server data (use TanStack Query)
  • Form state (use react-hook-form)

useState and useReducer

For component-local state, hooks are often enough:

// Simple state
const [isOpen, setIsOpen] = useState(false);

// Complex state with multiple related values
const [state, dispatch] = useReducer(reducer, initialState);

When local state is enough

  • Form inputs
  • Toggle states
  • Component-specific UI state
  • State that doesn't need to be shared

Lifting state up

When siblings need to share state:

function Parent() {
  const [selected, setSelected] = useState(null);
  
  return (
    <>
      <List onSelect={setSelected} />
      <Details item={selected} />
    </>
  );
}

If lifting becomes awkward, consider Zustand.

Combining patterns

A typical app uses multiple approaches:

// Server state: TanStack Query
const { data: user } = useQuery({
  queryKey: ['user'],
  queryFn: fetchCurrentUser,
});

// Global client state: Zustand
const theme = useThemeStore((s) => s.theme);
const sidebarOpen = useUIStore((s) => s.sidebarOpen);

// Local component state: useState
const [searchQuery, setSearchQuery] = useState('');

// Context: dependency injection
const api = useContext(ApiContext);

Migration from Redux

If you have a Redux codebase:

Redux pattern Modern replacement
API data in Redux TanStack Query
Thunks for async TanStack Query mutations
UI state slices Zustand stores
useSelector Zustand selectors
DevTools Zustand devtools middleware

Incremental migration

  1. Add TanStack Query for new API features
  2. Migrate existing API state to TanStack Query
  3. Replace simple Redux slices with Zustand
  4. Keep complex domain logic in Redux if working well

Debugging state

TanStack Query DevTools

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

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <MyApp />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

Zustand DevTools

import { devtools } from 'zustand/middleware';

const useStore = create(
  devtools((set) => ({
    // ... store
  }))
);

Works with Redux DevTools browser extension.

Validate API responses

Before debugging state issues, ensure your API responses are valid. Format and inspect with the JSON Formatter. Validate structure with the JSON Validator. Generate test data with the Fake Data Generator.

Common mistakes

Mistake Problem Fix
Putting API data in Zustand Duplicates TanStack Query features Use TanStack Query for server state
Over-using Context Re-renders entire tree Use Zustand for frequently changing state
Prop drilling instead of state Cumbersome, error-prone Lift to Zustand when 3+ levels deep
Not invalidating queries Stale data after mutations Call invalidateQueries in onSuccess
Fetching in useEffect Missing loading/error/caching Use TanStack Query

Decision flowchart

  1. Is it from an API? → TanStack Query
  2. Is it used by many components? → Zustand
  3. Is it theme/auth/config? → Context
  4. Is it component-specific? → useState
  5. Is it complex with many transitions? → useReducer or Zustand

Practical takeaway

Server state and client state need different tools. TanStack Query handles caching, refetching, and synchronization with your backend automatically. Zustand provides simple global state without Redux ceremony. Context injects dependencies, not state. useState handles local concerns. Pick the right tool for each type of state — most apps need a combination of all four approaches.

Sources

Try it on codefunc

Related articles

3

Search tools

Find a developer tool