React Context vs. Zustand vs. Jotai - When to Use What
AI generated
</>
{ }
React · State Management · Performance · Architecture
React Context vs. Zustand vs. Jotai
When and why to use each one

The wrong state management choice costs React projects more in performance and maintainability than almost any other architecture decision. Context, Zustand and Jotai solve the same underlying problem in fundamentally different ways. This article explains the differences with real code examples and shows when each tool is the right choice.

15 min read Context API · Zustand · Jotai · Re-render · Atoms React 18+ · TypeScript · Vite

1. Why state management in React is an architecture decision

State management in React projects is not a purely technical decision, it is an architectural one. Whoever picks the wrong solution early pays the price later in the form of uncontrolled re-renders, code that is hard to test, and complexity that grows with every new feature. The good news: React today offers three well documented approaches with Context API, Zustand and Jotai, each solving a specific class of problems without forcing every project to reach for Redux.

The decisive difference between the three approaches is not syntax, it is the mental model. Context API thinks top down: a provider holds the state, all consumers below react to changes. Zustand thinks from outside: a store exists outside the React component tree, and components subscribe to specific parts of it. Jotai thinks bottom up: individual atoms hold minimal state units, and more complex state is composed through derivation. Once you understand the mental model, you can decide within minutes which approach fits a given problem.

In practice, a common mistake is developers using Context API for global state that changes frequently, and then wondering why performance suffers. Or reaching for Zustand for theme settings and language configuration when Context would be entirely sufficient. The following sections systematically clarify when each tool is the right choice.

2. React Context API: strengths, limits and the re-render problem

The React Context API has been part of the core since React 16.3 and needs no external dependency. Its strength lies with state that rarely changes and must be available deep in the component tree: theme settings, user authentication, language configuration, feature flags. In these scenarios Context beats every external library, because the solution costs zero extra bytes and is integrated directly into React.

The classic problem with Context is its re-render behavior: every change to the context value triggers a re-render of all consumers, regardless of whether the consumed part of the value actually changed. A context holding an object with ten fields triggers a re-render of every component consuming that context when a single field changes. This problem can be limited with useMemo for the value and React.memo for components, but not fully eliminated. For frequently changing state with many consumers, Context is therefore structurally the wrong choice.


// auth-context.tsx - Context is ideal for infrequently changing state
import { createContext, useContext, useMemo, useState } from 'react';

interface AuthState {
  user: User | null;
  isLoading: boolean;
  login: (credentials: Credentials) => Promise<void>;
  logout: () => void;
}

const AuthContext = createContext<AuthState | null>(null);

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(false);

  // Memoize the value object to prevent unnecessary re-renders
  const value = useMemo(() => ({
    user,
    isLoading,
    login: async (credentials: Credentials) => {
      setIsLoading(true);
      const result = await authService.login(credentials);
      setUser(result.user);
      setIsLoading(false);
    },
    logout: () => setUser(null),
  }), [user, isLoading]);

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

// Custom hook with invariant - always use this, never useContext directly
export function useAuth(): AuthState {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error('useAuth must be used inside AuthProvider');
  return ctx;
}

3. Zustand: simple global state management without boilerplate

Zustand (from Poimandres, the same team behind Jotai and React Three Fiber) is a minimalist state management tool for React that keeps a global store outside the React component tree. The key difference from Context: components subscribe with a selector to exactly the slice of state they need, and only re-render when that specific slice changes. Zustand has no provider, needs no wrapping, and even works outside React components.

The Zustand API is deliberately minimal: create defines the store with initial state and actions, useStore(selector) subscribes to a slice. That is all. No reducers, no action creators, no dispatch. This simplicity makes Zustand especially suited for medium to large applications with global state that changes frequently: shopping cart state, UI state (open modals, active tabs), asynchronous data with loading states. Zustand supports middleware for logging, persistence (localStorage), Immer for immutable updates, and DevTools.


// cart-store.ts - Zustand store with TypeScript, Immer and persist middleware
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import { persist } from 'zustand/middleware';

interface CartItem { id: string; name: string; price: number; quantity: number; }

interface CartStore {
  items: CartItem[];
  total: number;
  addItem: (item: Omit<CartItem, 'quantity'>) => void;
  removeItem: (id: string) => void;
  updateQuantity: (id: string, quantity: number) => void;
  clearCart: () => void;
}

export const useCartStore = create<CartStore>()(
  persist(
    immer((set, get) => ({
      items: [],
      total: 0,

      addItem: (item) => set((state) => {
        const existing = state.items.find(i => i.id === item.id);
        if (existing) {
          existing.quantity += 1;
        } else {
          state.items.push({ ...item, quantity: 1 });
        }
        // Recalculate total after mutation
        state.total = state.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
      }),

      removeItem: (id) => set((state) => {
        state.items = state.items.filter(i => i.id !== id);
        state.total = state.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
      }),

      updateQuantity: (id, quantity) => set((state) => {
        const item = state.items.find(i => i.id === id);
        if (item) { item.quantity = Math.max(0, quantity); }
        state.total = state.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
      }),

      clearCart: () => set({ items: [], total: 0 }),
    })),
    { name: 'cart-storage' } // persists to localStorage automatically
  )
);

// Usage in component - only re-renders when items.length changes
function CartBadge() {
  const itemCount = useCartStore(state => state.items.length);
  return <span>{itemCount}</span>;
}

4. Jotai: atomic state management from the bottom up

Jotai is built on the atom concept, originally popularized by Recoil. An atom is the smallest unit of state: a piece of state with an initial value. More complex state emerges through derived atoms that combine or transform other atoms. This bottom-up model is the opposite of Context (top down) and complements Zustand well (which is optimized for vertical store state).

The practical advantage of Jotai lies in its granular reactivity: every component consuming an atom with useAtom only re-renders when that specific atom changes. Derived atoms with atom(get => …) automatically recompute when their dependencies change, similar to computed properties in Vue or MobX. Jotai is especially suited for complex local state graphs, for state shared between a small number of components without needing a global store, and for state with complex derivation chains.


// filter-atoms.ts - Jotai atoms for a product filter UI
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';

// Primitive atoms - the smallest state units
export const searchQueryAtom = atom('');
export const selectedCategoryAtom = atom<string | null>(null);
export const priceRangeAtom = atom({ min: 0, max: 1000 });
export const sortOrderAtom = atomWithStorage<'asc' | 'desc'>('sortOrder', 'asc');

// Derived atom - automatically recomputes when dependencies change
export const activeFilterCountAtom = atom((get) => {
  let count = 0;
  if (get(searchQueryAtom).length > 0) count++;
  if (get(selectedCategoryAtom) !== null) count++;
  const { min, max } = get(priceRangeAtom);
  if (min > 0 || max < 1000) count++;
  return count;
});

// Async derived atom - fetches based on filter state
export const filteredProductsAtom = atom(async (get) => {
  const query = get(searchQueryAtom);
  const category = get(selectedCategoryAtom);
  const { min, max } = get(priceRangeAtom);

  const response = await fetch(
    `/api/products?q=${query}&cat=${category ?? ''}&minPrice=${min}&maxPrice=${max}`
  );
  return response.json() as Promise<Product[]>;
});

// Component only re-renders when activeFilterCountAtom changes
function FilterBadge() {
  const [count] = useAtom(activeFilterCountAtom);
  return count > 0 ? <span className="badge">{count} filters active</span> : null;
}

5. Performance analysis: who re-renders when?

The practically most important difference between the three approaches is their re-render behavior. With Context API, every consumer re-renders when the context value changes, even if the consumed part of the value stays identical. This can only be mitigated by splitting into multiple, specialized contexts or by external selectors such as use-context-selector. With Zustand, a component only re-renders when the state slice it selected changes, determined by referential equality (shallow compare). With Jotai, a component only re-renders when the atom it consumes changes.

In practice this means: an application with a single global context object holding twenty different state values potentially triggers dozens of unnecessary re-renders on every single state change. The same application with a Zustand store or Jotai atoms for the same values only re-renders the components that actually consume the changed state. In large applications with complex component trees, this difference is clearly measurable with the React DevTools Profiler. The rule of thumb: Context for state that changes at most once or twice per session. Zustand or Jotai for everything else.

6. TypeScript integration compared

All three approaches offer strong TypeScript support, but in different ways. Context API requires explicit type annotation on the createContext call and returns null without a default value, which requires a null check in the custom hook. That is boilerplate, but type safe. Zustand is fully typed with the generic create<StoreType>() pattern, TypeScript infers the types of all selectors and actions automatically. Jotai depends on the atom type: an atom(0) is automatically a PrimitiveAtom<number>, and derived atoms inherit the types of their dependencies.

A practical difference shows up with complex store updates: Zustand with the Immer middleware allows mutating code that Immer internally translates into immutable updates, and TypeScript sees the correct, immutable type. Jotai with atomWithReducer or atomFamily requires more explicit type annotations, but offers very precise type safety for derivation chains in return. For large teams with strict TypeScript configuration, Zustand is often more maintainable than Jotai thanks to its explicit store typing.

7. DevTools, testing and debugging

Zustand offers the best DevTools integration: with the devtools middleware, every state change and action becomes visible in the Redux DevTools. That enables time travel debugging, action replay and state inspection directly in the browser, with zero configuration. Jotai also offers atom inspection for Chrome and Firefox through jotai/devtools. Context has no dedicated DevTools, but is fully visible through the React DevTools Component Inspector.

For testing, Context is easiest to mock: a test wrapper component with a different provider value replaces the state entirely. Zustand stores can be reset per test with beforeEach(() => useStore.setState(initialState)). Jotai offers an isolated store instance per test with createStore() and the Provider. All three approaches can be tested well with React Testing Library and Vitest, the effort is comparable, the patterns differ.

8. Migration and coexistence in existing projects

In practice the three approaches do not have to be alternatives, they can coexist without issue. A typical pattern in medium sized React applications: Context for auth and theme (rarely changing, deeply nested state), Zustand for global UI state and the shopping cart (frequently changing global state), Jotai for complex filter and form state graphs (local state with derivations). Each approach used in its own strength area beats a single approach for every scenario.

Migrating from Redux to Zustand is surprisingly straightforward in practice: a Redux reducer corresponds to a store slice in Zustand, actions become direct functions. The migration can happen incrementally, store by store, feature by feature. Context to Jotai is somewhat more involved because the mental model shifts more strongly, but a step by step migration is possible here too. The most common mistake during migrations: trying to switch all state management solutions at once instead of proceeding incrementally.

9. Decision matrix: Context vs. Zustand vs. Jotai

The choice between the three approaches should depend on the nature of the state and its usage patterns, not on personal preference or the popularity index of the library. The following matrix summarizes the most important decision criteria.

Criterion React Context Zustand Jotai
Change frequency Rare (1 to 2x per session) Frequent (every interaction) Frequent, granular
Bundle size 0 KB (built-in) ~1 KB gzip ~3 KB gzip
Boilerplate Medium (provider + hook) Minimal Minimal to medium
Re-render control Limited Very good (selectors) Very good (atoms)
DevTools React DevTools Redux DevTools Jotai DevTools
Ideal for Auth, theme, i18n Cart, UI state, API cache Filters, forms, graphs

A rule of thumb for everyday decisions: if the state originates in one component and is only passed downward, it stays local (useState). If the state is read by many components at different levels but rarely written, Context is the right choice. If the state changes frequently and is written and read by many components, Zustand is the first choice. If the state is heavily derived and needs granular reactivity, Jotai offers the most elegant solution.