AverageDevs
Next.jsReact

Recoil vs Zustand vs Context API for Next.js

A practical comparison for Next.js App Router projects - ergonomics, performance, SSR, server components, persistence, testing, and migration tips with TypeScript examples.

Recoil vs Zustand vs Context API for Next.js

State management is a tradeoff factory. In Next.js 14 with the App Router and Server Components, you can do a surprising amount without a global store. But as soon as you need cross page state, optimistic updates, or fine grained subscriptions, you will pick a tool. This guide compares Recoil, Zustand, and the built in Context API with a Next.js first mindset. We will keep the examples typed, minimize client JavaScript, and respect how Server Components change the usual advice.

Related reads: performance guardrails in Optimizing Core Web Vitals for Next.js, environment hygiene in TypeScript default for web development, and API boundaries in Integrate OpenAI API in Next.js.

The quick take - when to choose what

  • Use Context API when your state is small, updates are infrequent, and consumers are few. Think theme, auth user, feature flags.
  • Use Zustand when you want minimal boilerplate, excellent ergonomics, and very fast fine grained subscriptions with a small footprint.
  • Use Recoil when your app benefits from atoms and selectors with dependency graphs and you want derived state that updates only where needed.

Next.js App Router constraints you should care about

  • Prefer Server Components for data fetching and heavy logic. Keep client bundles thin.
  • State libraries run in client components only. Avoid coupling server logic to client stores.
  • For SSR hydration, ensure initial state is serialized once and rehydrated safely.

Context API - the baseline

Context works well for read heavy, rarely changing values. The common footgun is that any provider value change re renders all consumers under it. Memoize and split contexts to keep blast radius small.

// app/context/theme.tsx
"use client";
import { createContext, useContext, useMemo, useState } from "react";

type Theme = "light" | "dark";
type ThemeCtx = { theme: Theme; setTheme: (t: Theme) => void };

const ThemeContext = createContext<ThemeCtx | null>(null);

export const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
  const [theme, setTheme] = useState<Theme>("light");
  const value = useMemo(() => ({ theme, setTheme }), [theme]);
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
};

export const useTheme = () => {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
  return ctx;
};

Pros:

  • Built in, no deps, types are straightforward.
  • Good for small global values.

Cons:

  • Re renders can be coarse. Split providers or derive selectors manually.
  • No built in devtools or persistence helpers.

Zustand - small, fast, ergonomic

Zustand gives you a tiny store with selector subscriptions. It is an excellent default for app level state that changes often and needs to avoid cascading renders.

// lib/state/cart.ts
import { create } from "zustand";

type CartItem = { id: string; title: string; qty: number; price: number };
type CartState = {
  items: CartItem[];
  add: (item: CartItem) => void;
  remove: (id: string) => void;
  total: () => number;
};

export const useCart = create<CartState>((set, get) => ({
  items: [],
  add: (item) => set((s) => ({ items: [...s.items, item] })),
  remove: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
  total: () => get().items.reduce((sum, i) => sum + i.qty * i.price, 0),
}));
// components/CartTotal.tsx
"use client";
import { useCart } from "@/lib/state/cart";

export const CartTotal = () => {
  const total = useCart((s) => s.total()); // subscribes only to total()
  return <div>Total: ${total.toFixed(2)}</div>;
};

SSR notes:

  • For per user state, initialize from props in a wrapper provider, or keep the store entirely client side.
  • Avoid sharing mutable singletons between requests in Node. In Next.js, client stores live in the browser per session.

Pros:

  • Fast selector subscriptions minimize re renders.
  • Tiny API, easy to learn, great devtools and middleware for persist and undo.

Cons:

  • No concept of dependency graphs out of the box. You build derived state in the store or with selectors.

Recoil - atoms, selectors, and dependency graphs

Recoil models state as atoms and derived selectors. Components subscribe to the atoms they read. Updates propagate only where needed. The mental model fits complex UIs with many interdependent pieces.

// app/state/recoil.tsx
"use client";
import { RecoilRoot, atom, selector, useRecoilValue, useSetRecoilState } from "recoil";

type Product = { id: string; title: string; price: number };

export const cartAtom = atom<Product[]>({ key: "cart", default: [] });
export const cartTotalSelector = selector<number>({
  key: "cartTotal",
  get: ({ get }) => get(cartAtom).reduce((s, p) => s + p.price, 0),
});

export const AddToCart = ({ product }: { product: Product }) => {
  const setCart = useSetRecoilState(cartAtom);
  return <button onClick={() => setCart((c) => [...c, product])}>Add</button>;
};

export const CartTotal = () => {
  const total = useRecoilValue(cartTotalSelector);
  return <div>Total: ${total.toFixed(2)}</div>;
};

export const RecoilProviders = ({ children }: { children: React.ReactNode }) => (
  <RecoilRoot>{children}</RecoilRoot>
);

Pros:

  • Fine grained updates via atoms and selectors. Good for complex derived state.
  • Async selectors can orchestrate data dependencies.

Cons:

  • Additional concepts to learn. Some patterns differ from idiomatic React hooks.
  • SSR needs careful setup if you prefill atoms. Often you will keep atoms client side.

Performance and rendering behavior

Context re renders all consumers under a provider when its value changes. Mitigate by splitting contexts and memoizing values. Zustand and Recoil subscribe at field or atom granularity which reduces wasted renders.

ASCII sketch:

Update occurs
  Context Provider → many consumers re render
  Zustand selector → only components that read the selected slice re render
  Recoil atom     → only components that read that atom or derived selector re render

Measure on real pages with the React Profiler. For user facing impact, keep an eye on INP in our Core Web Vitals guide.

Persistence and offline

  • Context has no built in persistence. Use localStorage or IndexedDB manually.
  • Zustand ships a persist middleware for trivial localStorage or IndexedDB persistence.
  • Recoil has community persistence helpers or you can wire effects to storage.

For broader offline patterns, see Offline ready PWAs.

Testing and DX

  • Context: wrap components with providers and pass test values.
  • Zustand: reset the store between tests and use selectors for focused assertions.
  • Recoil: render under RecoilRoot and set initial atom values in tests.

TypeScript support is strong across all three. For developer experience tips that compound, revisit Why TypeScript is the default.

Server components and edge boundaries

Keep state on the client edge of your app tree. Pass primitives from Server Components into client components as props. Avoid reading from client stores inside RSC. When fetching data, keep it server side and push only the data needed for the client to render and interact.

Migration tips

  • Start with Context for simple globals. When performance degrades or shape grows, migrate the hot slice to Zustand or Recoil.
  • If you have complex derived state and many interdependent components, consider Recoil from the start.
  • If you need minimal code and fast iteration, Zustand is an easy win.

Summary recommendations

  • Small app or a few globals - Context with memoized values and split providers.
  • Medium app with interactive pages - Zustand with selector based reads and persist for session storage.
  • Complex app with graph like state - Recoil with atoms, selectors, and async selectors where appropriate.

Where to go next: