Skip to main content

State Management: Zustand vs Jotai in Boilerplates 2026

·StarterPick Team
Share:

TL;DR

  • Zustand is the default recommendation: minimal API, excellent DX, works everywhere, 1KB bundle.
  • Jotai is better for fine-grained subscriptions and atomic state—when you need parts of your UI to re-render independently.
  • In Next.js App Router, prefer server state (fetch, React Query, SWR) over client state stores whenever possible.
  • Both Zustand and Jotai work in React Client Components—neither works directly in Server Components.
  • Redux Toolkit remains relevant for complex enterprise apps with extensive middleware needs, but is overkill for most SaaS projects.
  • The 2026 pattern: React Query (server state) + Zustand (UI/client state) + shadcn/ui (local component state).

Key Takeaways

  • Most state in a well-designed Next.js App Router app is server state—use React Query or SWR, not a client store.
  • Zustand's create() API is minimal: define state + actions together in one call, subscribe anywhere.
  • Jotai's atom model is inspired by Recoil: each atom is an independent piece of state with its own subscribers.
  • Both are much smaller than Redux (~1KB vs Redux Toolkit's ~15KB gzipped).
  • Zustand stores need careful initialization for Next.js SSR—use the useRef-based pattern for SSR compatibility.
  • Jotai works better when state needs to be split across many components without prop drilling.

The State Management Landscape Has Simplified

The state management ecosystem in 2026 is dramatically simpler than it was in 2018. Understanding why it simplified is important context for making good architectural decisions — if you skip straight to the "Zustand or Jotai?" question without understanding the landscape, you will reach for a state management library for problems that do not need one.

Redux dominated React state management from roughly 2015 to 2020. It brought genuine innovations: a single store with a predictable mutation model, time-travel debugging through Redux DevTools, and an explicit separation between action dispatch and state mutation via reducers. These benefits were real, but the costs were high. Even simple state operations required boilerplate: action types, action creators, reducers, selectors, and then connecting it all with connect() or hooks. Teams spent significant time on Redux ceremony that had no direct relationship to the product they were building.

The state management landscape shifted in two distinct directions. React's own primitives improved: the Context API became more capable, useReducer provided Redux-like local state management without the global store ceremony, and the ecosystem developed better patterns for composing these primitives. Simultaneously, purpose-built lightweight libraries — Zustand, Jotai, Valtio, Recoil — took Redux's core insight (global accessible state) and shed its ceremony.

The most important conceptual shift came with TanStack Query (formerly React Query) and its clear articulation of the server state vs. client state distinction. Before this framing, it was natural to put everything in Redux or a similar store: user profile data, API responses, UI state, and everything else. React Query articulated that server data — anything that comes from an API, a database, or any async source — has fundamentally different characteristics than client state. Server data is asynchronous, can become stale, needs to be refetched, and should be shared and deduplicated across component trees. Client state is synchronous, only changes when the user does something, and does not need a caching layer.

Once you apply this framing to a typical Next.js SaaS application, the amount of state that actually needs a client state library shrinks dramatically. The user's profile? Server state — fetch it with React Query. The list of projects? Server state. The subscription status? Server state. What remains in client state: whether the sidebar is collapsed, which modal is currently open, the multi-step form's current step, the user's selected items in a bulk action interface, the notification queue. This is genuinely small. Many SaaS applications need only a few hundred bytes of global client state, and could implement that with React Context rather than Zustand if they wanted to.

The practical consequence for boilerplates is that the right recommendation is not "pick Zustand or Jotai and store everything in it." The right recommendation is "use TanStack Query for server state, use Zustand for the small amount of genuine client state you have, and be intentional about what actually needs to be global."


React Query / TanStack Query: The Other Half of the Story

Most state management articles for Next.js skip past or briefly mention TanStack Query (the official name for what was previously React Query), but for most SaaS applications it is the more important library — the one that handles the larger and more complex part of your state management needs.

TanStack Query is purpose-built for asynchronous server state. Its core abstractions are the query (reading data from a server) and the mutation (changing data on the server). The library handles caching, deduplication, background refetching, stale-while-revalidate patterns, optimistic updates, and the complete lifecycle of loading, error, and success states.

The caching behavior alone eliminates a common source of unnecessary complexity. Without TanStack Query, if two components on the same page both need the current user's subscription status, you face a choice: fetch it twice (wasteful), lift it to a shared parent (prop drilling), or put it in a global store (adds complexity). With TanStack Query, both components call useQuery({ queryKey: ['subscription'], queryFn: fetchSubscription }) independently, and TanStack Query ensures only one network request is made — the second call receives the cached result from the first.

Background refetching means your UI stays current without manual refresh logic. When a user returns to a browser tab after switching away, TanStack Query automatically refetches stale queries in the background and updates the UI when the new data arrives. This is behavior that previously required significant custom implementation.

Optimistic updates and mutation invalidation address the other common complexity: updating the UI after a mutation. The pattern is straightforward:

export function useUpgradePlan() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (plan: string) => {
      const response = await fetch("/api/billing/upgrade", {
        method: "POST",
        body: JSON.stringify({ plan }),
        headers: { "Content-Type": "application/json" },
      });
      if (!response.ok) throw new Error("Upgrade failed");
      return response.json();
    },
    onSuccess: () => {
      // Invalidate and refetch subscription data after successful upgrade
      queryClient.invalidateQueries({ queryKey: ["subscription"] });
      queryClient.invalidateQueries({ queryKey: ["user"] });
    },
    onMutate: async (plan) => {
      // Optionally: optimistic update before the mutation completes
      await queryClient.cancelQueries({ queryKey: ["subscription"] });
      const previous = queryClient.getQueryData(["subscription"]);
      queryClient.setQueryData(["subscription"], (old: any) => ({
        ...old,
        plan,
        status: "active",
      }));
      return { previous };
    },
    onError: (_, __, context) => {
      // Roll back optimistic update on error
      queryClient.setQueryData(["subscription"], context?.previous);
    },
  });
}

In Next.js App Router, TanStack Query works exclusively in Client Components — it requires the React hooks API and the browser environment. This works alongside Server Components naturally: Server Components handle the initial data fetch for page loads (fast, no client JavaScript required), and Client Components use TanStack Query for interactive data that needs to be updated, refetched, or mutated in response to user actions.

The practical result: when you integrate TanStack Query properly, you find that Zustand or Jotai stores shrink significantly. You stop storing API responses in global state (TanStack Query caches those). You stop writing manual loading and error state (TanStack Query tracks those). What remains is the thin layer of UI state that is genuinely client-only and not derivable from server data.


When to Use Context vs Zustand vs Jotai

The practical decision among React Context, Zustand, and Jotai comes down to three factors: how frequently the state changes, how many components need to consume it, and how granular the subscription needs to be.

React Context is the right choice for state that changes infrequently and needs to reach deeply nested components. The canonical examples are the current user object (changes once on login/logout), the active theme (changes when the user manually toggles it), and feature flags (changes rarely, if ever, in a session). Context has a significant limitation: every consumer re-renders when the context value changes, regardless of which part of the value they use. For a context value that changes frequently or has many consumers, this becomes a performance problem.

Zustand is the right choice for client state that is updated with moderate frequency and accessed by components that are spread across the component tree. The classic SaaS examples: the notification queue (new notifications arrive, old ones are dismissed), a multi-step form where state persists across navigation between steps, a shopping cart or selection state where multiple components need to read and write, and the sidebar open/close state when multiple components need to trigger and respond to it. Zustand's selector-based subscription model means components only re-render when the specific slice of state they select changes — other changes to the store do not cause re-renders.

Jotai is the right choice when state is naturally decomposed into many small independent pieces and you need components to re-render only when their specific atom changes. The prototypical example is a table with row selection: each row's selection state is an independent atom, and only the component rendering that row re-renders when its selection changes. In Zustand, you would need to carefully write selectors to achieve the same granularity. In Jotai, it is the default behavior.

The "prop drilling" problem — needing to pass state through many component layers to reach a deeply nested consumer — is often the trigger for reaching for a state management solution. But it is worth asking whether component composition would solve the problem first. If the state is genuinely local to a subtree and is not needed by components outside that subtree, lifting it to a Zustand store adds unnecessary global accessibility to something that should be contained. The slot pattern and compound component patterns can often eliminate prop drilling within a component tree without introducing global state.

The practical default for a new SaaS boilerplate in 2026: start with TanStack Query for all server data, start with React Context for user identity and theme, start with useState for all component-local state, and add Zustand if and when you encounter state that genuinely needs to be accessed by many components at different points in the tree and changes frequently enough that Context's universal re-render would cause performance issues. Most solo and small-team SaaS projects never outgrow this setup.


Persistence and Hydration

Persisting state across page reloads and browser sessions is a common requirement for SaaS applications. Remembering the user's sidebar collapse preference, their selected team in a multi-tenant app, or their table view configuration improves the experience significantly. But persistence introduces a subtle bug class that is important to understand before implementing.

The problem is the SSR hydration mismatch. When Next.js renders a page on the server, it has no access to the browser's localStorage or sessionStorage. The server renders the page with the default state. When the page loads in the browser and React hydrates, it reads the persisted state from localStorage and discovers it differs from what the server rendered. React logs a hydration mismatch warning, and in some cases produces incorrect UI until the mismatch is resolved.

Zustand's persist middleware handles the persistence mechanics but does not automatically solve the hydration mismatch problem. The solution is the skipHydration option combined with a manual rehydrate() call inside a useEffect:

import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";

interface PreferencesState {
  sidebarCollapsed: boolean;
  selectedTeamId: string | null;
  _hasHydrated: boolean;
  setSidebarCollapsed: (collapsed: boolean) => void;
  setSelectedTeam: (teamId: string | null) => void;
  setHasHydrated: (state: boolean) => void;
}

export const usePreferences = create<PreferencesState>()(
  persist(
    (set) => ({
      sidebarCollapsed: false,
      selectedTeamId: null,
      _hasHydrated: false,
      setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
      setSelectedTeam: (teamId) => set({ selectedTeamId: teamId }),
      setHasHydrated: (state) => set({ _hasHydrated: state }),
    }),
    {
      name: "user-preferences",
      storage: createJSONStorage(() => localStorage),
      skipHydration: true,
      onRehydrateStorage: () => (state) => {
        state?.setHasHydrated(true);
      },
    }
  )
);

Then in a client component that needs the persisted state, hydrate explicitly:

"use client";
import { useEffect } from "react";
import { usePreferences } from "@/stores/preferences";

export function HydrationBoundary({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    usePreferences.persist.rehydrate();
  }, []);

  return <>{children}</>;
}

This pattern ensures that the server and the initial client render are identical (using default state), and localStorage state is applied only after hydration is complete.

Jotai's atomWithStorage from the jotai/utils package handles persistence at the atom level. It reads from localStorage synchronously on the client during initial render, which means the initial render in the browser uses the persisted value — but this also means the server render and the first client render will differ, triggering the same hydration mismatch. The useHydrateAtoms utility from jotai/utils can be used to synchronize initial atom values from server-provided data, addressing the hydration problem for atoms whose values are known server-side.

What Should Not Be Persisted

The boundary of what is safe to persist in localStorage is important for security and reliability. Sensitive data should never go in localStorage: auth tokens, session identifiers, payment method details, or any information that would be damaging if exfiltrated. Auth tokens belong in httpOnly cookies — they are inaccessible to JavaScript, which means an XSS attack on your application cannot steal them. The fact that localStorage is accessible to any JavaScript running on your page, including injected scripts from compromised dependencies, makes it unsuitable for anything sensitive.

A practical rule: localStorage is appropriate for presentation preferences (sidebar state, selected view, column visibility, theme) and non-sensitive identifiers (selected team ID, where the team membership can be fetched and verified server-side). It is not appropriate for anything that grants access or that contains private data.

The other consideration is that localStorage is per-device. If a user switches from their work computer to their laptop, their localStorage preferences do not follow them. For preferences that should be consistent across devices — selected team, UI density preferences that affect productivity — consider storing them in the database and fetching them as server state on each session start. This is more complex to implement but significantly better for users who use multiple devices.


The State Management Landscape in 2026

Before choosing a state management library, clarify what kind of state you're managing:

State TypeBest Solution
Server data (user profile, projects, billing)React Query / SWR / Server Components
Form stateReact Hook Form
URL state (filters, pagination)useSearchParams + nuqs
UI state (modals, sidebar open, theme)Zustand or Jotai
Component local stateuseState / useReducer

The key insight: In App Router, most "global" state is actually server state that can be fetched directly in Server Components. A well-designed SaaS app uses Zustand or Jotai only for genuinely client-side UI state.


Zustand

Setup and Basic Usage

npm install zustand
// src/stores/ui-store.ts
import { create } from "zustand";

interface UIState {
  // State
  sidebarOpen: boolean;
  activeModal: string | null;
  notifications: Notification[];

  // Actions
  toggleSidebar: () => void;
  setSidebarOpen: (open: boolean) => void;
  openModal: (modalId: string) => void;
  closeModal: () => void;
  addNotification: (notification: Notification) => void;
  dismissNotification: (id: string) => void;
}

export const useUIStore = create<UIState>((set) => ({
  sidebarOpen: true,
  activeModal: null,
  notifications: [],

  toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
  setSidebarOpen: (open) => set({ sidebarOpen: open }),
  openModal: (modalId) => set({ activeModal: modalId }),
  closeModal: () => set({ activeModal: null }),
  addNotification: (notification) =>
    set((state) => ({
      notifications: [...state.notifications, notification],
    })),
  dismissNotification: (id) =>
    set((state) => ({
      notifications: state.notifications.filter((n) => n.id !== id),
    })),
}));

Usage in components:

// Only subscribes to the specific slice it needs
function Sidebar() {
  const isOpen = useUIStore((state) => state.sidebarOpen);
  const toggle = useUIStore((state) => state.toggleSidebar);

  return (
    <aside className={cn("w-64", !isOpen && "hidden")}>
      <button onClick={toggle}>Toggle</button>
    </aside>
  );
}

Persistent State

import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";

interface UserPreferences {
  theme: "light" | "dark" | "system";
  density: "compact" | "comfortable" | "spacious";
  setTheme: (theme: UserPreferences["theme"]) => void;
  setDensity: (density: UserPreferences["density"]) => void;
}

export const usePreferences = create<UserPreferences>()(
  persist(
    (set) => ({
      theme: "system",
      density: "comfortable",
      setTheme: (theme) => set({ theme }),
      setDensity: (density) => set({ density }),
    }),
    {
      name: "user-preferences", // localStorage key
      storage: createJSONStorage(() => localStorage),
    }
  )
);

SSR-Safe Pattern for Next.js

Zustand stores initialized during SSR can cause hydration mismatches. The standard pattern:

// src/providers/store-provider.tsx
"use client";

import { createContext, useContext, useRef } from "react";
import { createStore, StoreApi, useStore } from "zustand";

interface UIState {
  sidebarOpen: boolean;
  toggleSidebar: () => void;
}

const createUIStore = (initialState?: Partial<UIState>) =>
  createStore<UIState>()((set) => ({
    sidebarOpen: true,
    ...initialState,
    toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
  }));

const StoreContext = createContext<StoreApi<UIState> | null>(null);

export function StoreProvider({
  children,
  initialState,
}: {
  children: React.ReactNode;
  initialState?: Partial<UIState>;
}) {
  const storeRef = useRef<StoreApi<UIState>>();
  if (!storeRef.current) {
    storeRef.current = createUIStore(initialState);
  }

  return (
    <StoreContext.Provider value={storeRef.current}>
      {children}
    </StoreContext.Provider>
  );
}

export function useUIStore<T>(selector: (state: UIState) => T): T {
  const store = useContext(StoreContext);
  if (!store) throw new Error("useUIStore must be used within StoreProvider");
  return useStore(store, selector);
}

This pattern ensures one store instance per Next.js request on the server, and one instance per browser tab on the client.


Jotai

Setup and Basic Usage

npm install jotai

Jotai's model: each piece of state is an "atom". Components subscribe to specific atoms and only re-render when those atoms change.

// src/atoms/ui.ts
import { atom } from "jotai";

// Primitive atoms
export const sidebarOpenAtom = atom(true);
export const activeModalAtom = atom<string | null>(null);

// Derived atom (computed from other atoms)
export const isSidebarVisibleAtom = atom(
  (get) => get(sidebarOpenAtom) && !get(activeModalAtom)
);

// Read-write derived atom
export const themeAtom = atom(
  (get) => get(userPreferencesAtom).theme,
  (get, set, theme: "light" | "dark" | "system") => {
    const prefs = get(userPreferencesAtom);
    set(userPreferencesAtom, { ...prefs, theme });
  }
);

Usage:

import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { sidebarOpenAtom } from "@/atoms/ui";

function Sidebar() {
  const [isOpen, setOpen] = useAtom(sidebarOpenAtom);
  // OR: const isOpen = useAtomValue(sidebarOpenAtom); // read only, no setter
  // OR: const setOpen = useSetAtom(sidebarOpenAtom);  // write only, no re-render on read

  return <aside className={cn("w-64", !isOpen && "hidden")}>...</aside>;
}

The useAtomValue and useSetAtom hooks let you minimize unnecessary re-renders—a component that only sets state doesn't need to subscribe to its value.

Atom with Storage

import { atomWithStorage } from "jotai/utils";

export const themeAtom = atomWithStorage<"light" | "dark" | "system">(
  "theme",
  "system"
);

Complex Atoms

// Async atom (reads from API)
import { atom } from "jotai";

export const userProfileAtom = atom(async (get) => {
  const userId = get(currentUserIdAtom);
  if (!userId) return null;
  const response = await fetch(`/api/users/${userId}`);
  return response.json();
});

// Atom family (parameterized atoms)
import { atomFamily } from "jotai/utils";

export const projectAtomFamily = atomFamily((projectId: string) =>
  atom(async () => {
    const response = await fetch(`/api/projects/${projectId}`);
    return response.json();
  })
);

Zustand vs Jotai: Decision Guide

FactorZustandJotai
Bundle size~1KB~3KB
API simplicityVery simpleSimple but more concepts
Fine-grained subscriptionsManual (selectors)Built-in (atom granularity)
Derived stateManualBuilt-in (derived atoms)
Async stateManualBuilt-in (async atoms)
Devtools
SSR safetyRequires patternBetter by default
Learning curveVery lowLow
Best forSingle store, actionsMany independent pieces of state

When to Choose Zustand

  • You have a clear "store" mental model (actions mutate state, components subscribe)
  • Your state is mostly flat (not deeply nested graph)
  • You want the absolute minimum API to learn
  • You need extensive middleware (persistence, logging, devtools)

When to Choose Jotai

  • Your state has many independent pieces (many atoms, each with specific subscribers)
  • You need derived/computed state that's automatically kept in sync
  • You want components to subscribe to atomic pieces without manual selector optimization
  • You're coming from Recoil and want similar patterns

React Query for Server State

For state that comes from the server (user data, projects, billing), React Query is the right tool—not Zustand or Jotai:

// src/hooks/use-subscription.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";

export function useSubscription() {
  return useQuery({
    queryKey: ["subscription"],
    queryFn: async () => {
      const response = await fetch("/api/billing/subscription");
      return response.json();
    },
    staleTime: 5 * 60 * 1000, // 5 minutes
  });
}

export function useUpgradePlan() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (plan: string) => {
      const response = await fetch("/api/billing/upgrade", {
        method: "POST",
        body: JSON.stringify({ plan }),
      });
      return response.json();
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["subscription"] });
    },
  });
}

For how state management fits with authentication patterns, see authentication setup in Next.js boilerplates. For how the TypeScript configuration supports type-safe state, see TypeScript config: boilerplate best practices. For boilerplates that include state management setup, see best Next.js boilerplates 2026.


Methodology

Analysis based on npm download trends (npmtrends.com, Q1 2026), GitHub star/activity metrics, bundle size measurements from Bundlephobia, and review of state management patterns in production Next.js App Router applications. Performance claims based on React DevTools profiling of representative patterns.

The SaaS Boilerplate Matrix (Free PDF)

20+ SaaS starters compared: pricing, tech stack, auth, payments, and what you actually ship with. Updated monthly. Used by 150+ founders.

Join 150+ SaaS founders. Unsubscribe in one click.