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)
Anti-Pattern: Redux for Server State
This pattern is extremely common in older codebases and causes real maintenance pain:
// ✗ 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:// ✓ 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:
// ✓ 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 otherMigration Strategy
If you have an existing Redux codebase and want to modernize:
Identify all Redux state that is server data (API responses). Migrate those slices to TanStack Query first — this is the highest-value change.
Identify Redux state that is genuinely client-side (UI toggles, cart, user preferences). Migrate those to Zustand.
Remove Redux entirely once all slices are migrated.
Keep React Hook Form for forms — do not put form state in any global store.