ReactChoosing a State Library

Choosing a State Library

React ships with useState, useReducer, and useContext — enough for most apps. But as apps grow, sharing state across distant components, synchronizing server data, or managing complex async workflows push built-in tools to their limits. A third-party library can help — but which one?

The decision is rarely urgent. Start with React's built-ins and introduce a library only when you feel friction. When you do, pick the one that fits the problem shape, not the one with the most GitHub stars.

Library Comparison

Library

Bundle

Learning Curve

DevTools

SSR

TypeScript

Best For

useState + Context

0 kB

Low

React DevTools

First-class

First-class

Small apps, local UI state

Zustand

~3 kB

Low

Redux DevTools

Good

Excellent

Shared client state, most apps

Jotai

~3 kB

Low

Jotai DevTools

Good

Excellent

Atomic UI state, per-entity state

Redux Toolkit

~47 kB

Moderate

Redux DevTools

Good

Good

Large teams, complex flows

Recoil

~20 kB

Moderate

Recoil DevTools

Needs care

Good

Atomic state (Meta apps)

TanStack Query

~13 kB

Moderate

Query DevTools

First-class

Excellent

Server/async data

Recommendation by Scenario

Tiny app or prototype — stick with useState and pass props. Adding a library for two or three shared values is premature. If prop drilling becomes annoying across three levels, try useContext before reaching for a library.

Sharing some client state — reach for Zustand. One create() call gives you a store accessible from anywhere without a Provider. It is the best default for most apps that need global state.

Highly granular UI state — try Jotai. When you need dozens of independent toggles, per-item state, or async data per entity, Jotai's atom model keeps re-renders minimal and the code declarative.

Large enterprise app with strict conventions — use Redux Toolkit. Its slice pattern, Immer integration, and excellent DevTools shine when many engineers work on the same codebase and need predictable, auditable state mutations.

Data fetching and server state — use TanStack Query (or SWR). These libraries handle caching, deduplication, background revalidation, pagination, optimistic updates, and loading/error states automatically. No custom useEffect fetch logic needed.

The Core Rule: One Library Per Concern

The most important principle: do not use multiple state libraries to manage the same concern. Pick one approach per problem type and be consistent:

  • Server/remote data → TanStack Query (not Zustand, not Redux)

  • Client UI state → Zustand or Jotai (not TanStack Query)

  • Local component state → useState (not a global store)

  • Form state → React Hook Form (not useState for every field)

Warning
The most common anti-pattern: storing server data in Redux or Zustand and managing cache invalidation manually. This re-implements what TanStack Query already does — with more bugs and more code. Let TanStack Query own server state.
Anti-Pattern: Redux for Server State

This pattern is extremely common in older codebases and causes real maintenance pain:

TS
// ✗ Anti-pattern: treating server data like client state
const usersSlice = createSlice({
  name: 'users',
  initialState: { data: [], loading: false, error: null },
  reducers: {
    fetchUsersStart(state) { state.loading = true },
    fetchUsersSuccess(state, action) {
      state.loading = false
      state.data = action.payload
    },
    fetchUsersError(state, action) {
      state.loading = false
      state.error = action.payload
    },
  },
})

// You still need a thunk, a useEffect caller, cache invalidation logic...
// That is hundreds of lines to do what TanStack Query does in 3:

TSX
// ✓ Use TanStack Query for server data
import { useQuery } from '@tanstack/react-query'

function UserList() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: () => fetch('/api/users').then((r) => r.json()),
    staleTime: 5 * 60 * 1000,  // cache for 5 minutes
  })

  if (isLoading) return <p>Loading…</p>
  if (error) return <p>Error loading users</p>

  return <ul>{data.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
}
Decision Flowchart
  • Is the data from a server? → TanStack Query or SWR.

  • Is it local component state only? → useState.

  • Is it shared between a few nearby components? → Lift state up + props, or useContext.

  • Is it global client state accessed from many places? → Zustand.

  • Is it atomic or per-entity state? → Jotai.

  • Do you need strict Redux DevTools / audit trail? → Redux Toolkit.

Mixing Libraries Safely

Using more than one library in the same app is fine — as long as each library owns a distinct concern. The combination that works extremely well in practice:

TSX
// ✓ Each library manages what it is best at

// 1. TanStack Query for server/async data
const { data: products } = useQuery({ queryKey: ['products'], queryFn: fetchProducts })

// 2. Zustand for global client state
const cartItems = useCartStore((state) => state.items)
const addToCart = useCartStore((state) => state.addItem)

// 3. useState for local component state
const [isMenuOpen, setIsMenuOpen] = useState(false)

// 4. React Hook Form for form state
const { register, handleSubmit } = useForm()

// These four concerns never bleed into each other
Note
TanStack Query + Zustand is the most popular combination in modern React apps. TanStack Query handles all async/server data, Zustand handles the small amount of client state that truly needs to be global (auth, cart, theme). Everything else stays in useState.
Migration Strategy

If you have an existing Redux codebase and want to modernize:

  1. Identify all Redux state that is server data (API responses). Migrate those slices to TanStack Query first — this is the highest-value change.

  2. Identify Redux state that is genuinely client-side (UI toggles, cart, user preferences). Migrate those to Zustand.

  3. Remove Redux entirely once all slices are migrated.

  4. Keep React Hook Form for forms — do not put form state in any global store.

Tip
You do not need to migrate all at once. TanStack Query, Zustand, and Redux can coexist in the same app during a gradual migration. Migrate one slice at a time and ship between each step.