Zustand is a minimalist state management library that avoids Context API’s re-render problems. Where Context re-renders every consumer when any value changes, Zustand components only re-render when the specific slice of state they subscribe to changes. The API is simpler too: no provider wrapper, no reducer boilerplate — just a single create() call that defines both state and actions, and a hook that any component can call. For the blog application’s auth state, Zustand is a drop-in replacement for the Context-based approach with better performance characteristics.
Zustand Auth Store
npm install zustand
// src/stores/authStore.js
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { authApi } from "@/services/auth";
export const useAuthStore = create(
persist(
(set, get) => ({
// ── State ─────────────────────────────────────────────────────────
user: null,
accessToken: null,
isLoading: true,
// ── Derived ───────────────────────────────────────────────────────
get isLoggedIn() { return Boolean(get().user); },
// ── Actions ───────────────────────────────────────────────────────
init: async () => {
const token = get().accessToken;
if (!token) { set({ isLoading: false }); return; }
try {
const user = await authApi.me();
set({ user, isLoading: false });
} catch {
set({ user: null, accessToken: null, isLoading: false });
}
},
login: async (email, password) => {
const { access_token, refresh_token } = await authApi.login(email, password);
const user = await authApi.me();
// persist middleware saves to localStorage automatically
set({ user, accessToken: access_token });
localStorage.setItem("refresh_token", refresh_token);
return user;
},
logout: async () => {
const refreshToken = localStorage.getItem("refresh_token");
try { await authApi.logout(refreshToken); } catch {}
localStorage.removeItem("refresh_token");
set({ user: null, accessToken: null });
},
setUser: (user) => set({ user }),
}),
{
name: "auth", // localStorage key
partialize: (state) => ({ accessToken: state.accessToken }),
// Only persist the access token — not the user object or loading state
}
)
);
Note: The
persist middleware automatically saves and reloads specified state slices to localStorage. The partialize option lets you choose which fields to persist — here, only the accessToken is persisted (user data is re-fetched from the API on startup to ensure freshness). The key name ("auth") is the localStorage key where the data is stored. On the next page load, Zustand reads the stored token and init() validates it.Tip: Zustand’s selector pattern lets components subscribe to only the slice of state they need. Instead of
const store = useAuthStore() (subscribes to all changes), use const user = useAuthStore(s => s.user) (only re-renders when user changes). This fine-grained subscription is what makes Zustand more performant than Context for frequently-changing state — the Header component subscribes to only user and is not affected by isLoading changes during login.Warning: The
persist middleware stores state as JSON in localStorage. Never store sensitive data like passwords or private keys in the store — they will end up in localStorage. The access token itself is sensitive, but since it has a short expiry (15 minutes) and is required to work at all, storing it client-side is a reasonable trade-off. Treat the token like a session cookie — protect the page with HTTPS and a Content Security Policy.Using the Zustand Store in Components
import { useEffect } from "react";
import { useAuthStore } from "@/stores/authStore";
// ── Initialize on app startup ─────────────────────────────────────────────────
function App() {
const init = useAuthStore((s) => s.init);
useEffect(() => { init(); }, [init]);
return <Routes />;
}
// ── Header — only subscribes to user and logout ───────────────────────────────
function Header() {
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
return (
<header>
{user ? (
<>
<span>{user.name}</span>
<button onClick={logout}>Sign Out</button>
</>
) : (
<a href="/login">Sign In</a>
)}
</header>
);
// Header re-renders ONLY when user changes — not when isLoading changes
}
// ── ProtectedRoute — subscribes to isLoading and isLoggedIn ──────────────────
function ProtectedRoute() {
const isLoading = useAuthStore((s) => s.isLoading);
const isLoggedIn = useAuthStore((s) => s.isLoggedIn);
if (isLoading) return <Spinner />;
if (!isLoggedIn) return <Navigate to="/login" replace />;
return <Outlet />;
}
Context vs Zustand Comparison
| Feature | Context API | Zustand |
|---|---|---|
| Provider needed | Yes (wraps app) | No (call hook anywhere) |
| Re-render scope | All consumers on any change | Only subscribers to changed slice |
| Boilerplate | Moderate | Minimal |
| Devtools | No | Yes (Zustand devtools) |
| Persistence | Manual localStorage | Built-in persist middleware |
| Best for | Theme, locale, rare changes | Auth, cart, complex UI state |