Zustand, Jotai & Modern Alternatives
Zustand, Jotai & Modern Alternatives
LinkedIn Hook
Your team spends a week configuring Redux. Actions, reducers, slices, selectors, thunks, middleware, store setup. Then you look at the actual business logic — it is 12 lines.
The boilerplate-to-logic ratio is 8:1. That is not state management. That is paperwork.
This is exactly why Zustand, Jotai, and Valtio exist. They did not emerge because Redux was broken. They emerged because Redux was overkill for what most applications actually need.
Zustand gives you a global store in five lines — no providers, no context, no boilerplate. Jotai gives you atomic state that composes like React's own useState, except the atoms are global and shareable. Valtio lets you mutate state directly with proxies and React stays in sync automatically.
In interviews, they will not just ask you "what is Zustand?" They will ask: "Why would you choose Zustand over Redux?" "What problem does atomic state solve that a centralized store does not?" "When would you still reach for Redux Toolkit?"
These questions test whether you understand the tradeoffs — not just the syntax.
In this lesson, I cover why these libraries emerged, how Zustand and Jotai work under the hood, when each approach shines, and a comparison table you can use in interviews to justify your choices with precision.
If your answer to "which state library would you use?" is always "Redux" — this lesson gives you three more options and the reasoning to pick the right one.
Read the full lesson -> [link]
#React #JavaScript #InterviewPrep #Frontend #StateManagement #Zustand #Jotai #Redux #CodingInterview #100DaysOfCode
What You'll Learn
- Why modern state management libraries emerged and what problems they solve that Redux and Context could not
- How Zustand works — creating a store, reading slices, updating state, and why it needs no Provider
- The atomic state concept behind Jotai and how it differs from centralized stores
- A comparison table across Context, Redux Toolkit, Zustand, and Jotai for interview-ready answers
- How to choose the right state management tool based on project constraints
The Concept — Why New Libraries Emerged
Analogy: The Restaurant Kitchen
Imagine three different restaurant kitchens.
Redux is a five-star hotel kitchen. It has a head chef (store), strict recipes (reducers), formal order tickets (actions), a ticket rail system (middleware), and a wall of security cameras (DevTools). Every dish goes through the same rigorous process. For a banquet serving 500 guests, this system is essential — it prevents chaos. But when you just want to make a sandwich, you still have to write a ticket, submit it to the head chef, wait for the reducer to process it, and pick it up from the output window. That is too much ceremony for a sandwich.
Zustand is a food truck. One person, one grill, no tickets. You say "I want a burger," and the cook makes it. The entire operation fits in a small space, yet it can serve hundreds of customers efficiently. There is no head chef, no formal ticket system — just a direct interface. You get the same quality output with a fraction of the overhead.
Jotai is a potluck dinner. There is no central kitchen at all. Each person brings one dish (atom). Anyone at the table can eat from any dish. If someone refills the salad bowl, only the people who took salad notice — the people eating pasta are unaffected. Each dish is independent, yet they compose into a full meal. No single point of control, no bottleneck, no ceremony.
The takeaway: Redux optimizes for predictability and scale at the cost of boilerplate. Zustand optimizes for simplicity while keeping enough structure. Jotai optimizes for composability and fine-grained reactivity. The right choice depends on what your project actually needs — not what is most popular.
Zustand — The Simple Global Store
Zustand (German for "state") is a small, fast state management library that gives you a global store without providers, reducers, or boilerplate.
Why Zustand Works Without a Provider
Most state libraries use React Context to deliver state to components. Zustand takes a different approach: it creates a store outside of React and uses useSyncExternalStore (a React hook) to subscribe components directly to the store. No Provider means no context overhead, no Provider nesting, and no re-render propagation through the tree.
Code Example 1: Zustand — Basic Store in 10 Lines
import { create } from "zustand";
// Create a store — this lives OUTSIDE of React
// No provider, no context, no reducer — just state and actions
const useCounterStore = create((set) => ({
count: 0,
// Actions are just functions that call set() to update state
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
// Component subscribes to the ENTIRE store
function Counter() {
const { count, increment, decrement, reset } = useCounterStore();
return (
<div>
<h2>Count: {count}</h2>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
<button onClick={reset}>Reset</button>
</div>
);
}
// Component subscribes to ONLY count — does not re-render when actions change
function CountDisplay() {
// Selector: only re-render when count changes
const count = useCounterStore((state) => state.count);
return <p>The current count is {count}</p>;
}
// No Provider needed — just use the hook anywhere in the app
function App() {
return (
<div>
<Counter />
<CountDisplay />
</div>
);
}
// Clicking +1 three times:
// Output: "Count: 3" and "The current count is 3"
// Clicking Reset:
// Output: "Count: 0" and "The current count is 0"
Key point: The selector (state) => state.count is what gives Zustand its performance advantage. Components only re-render when their selected slice changes — no Context re-render problem, no need to split providers.
Code Example 2: Zustand — Real-World Store with Async and Middleware
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
// A real-world store with async logic, devtools, and persistence
const useAuthStore = create(
// devtools wraps the store for Redux DevTools integration
devtools(
// persist saves state to localStorage automatically
persist(
(set, get) => ({
user: null,
isLoading: false,
error: null,
// Async action — no thunks, no middleware config needed
login: async (email, password) => {
set({ isLoading: true, error: null });
try {
const response = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!response.ok) throw new Error("Login failed");
const user = await response.json();
// set() merges by default — only updates the keys you specify
set({ user, isLoading: false });
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
logout: () => set({ user: null, error: null }),
// get() reads current state inside an action
isAuthenticated: () => get().user !== null,
}),
{
name: "auth-storage", // key for localStorage
// Only persist the user — not loading or error states
partialize: (state) => ({ user: state.user }),
}
),
{ name: "AuthStore" } // label in Redux DevTools
)
);
// Component usage — clean and minimal
function LoginForm() {
const { login, isLoading, error } = useAuthStore();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
async function handleSubmit(e) {
e.preventDefault();
await login(email, password);
}
return (
<form onSubmit={handleSubmit}>
{error && <p style={{ color: "red" }}>{error}</p>}
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button disabled={isLoading}>
{isLoading ? "Logging in..." : "Log In"}
</button>
</form>
);
}
// Header only subscribes to user — does not re-render on loading or error
function Header() {
const user = useAuthStore((state) => state.user);
return <header>{user ? `Hello, ${user.name}` : "Please log in"}</header>;
}
// After successful login:
// Output: Header shows "Hello, Alice"
// After page refresh (persistence):
// Output: Header still shows "Hello, Alice" — state restored from localStorage
Key point: Zustand handles async operations directly inside actions — no thunks, no sagas, no separate middleware configuration. The devtools and persist middleware compose cleanly by wrapping the store definition.
Jotai — The Atomic State Concept
Jotai (Japanese for "state") takes a fundamentally different approach. Instead of one centralized store, you define independent atoms — small pieces of state that components can subscribe to individually.
How Atomic State Differs from a Store
In Redux or Zustand, all state lives in one object. Even with selectors, the mental model is "one store, many subscribers." In Jotai, each piece of state is an independent atom. Atoms can depend on other atoms (derived atoms), creating a reactive graph. When one atom updates, only the components subscribed to that specific atom re-render.
Think of it as the difference between a spreadsheet with one giant table (centralized store) versus a spreadsheet with individual cells that reference each other (atoms). Changing cell A1 automatically updates cell B1 if B1's formula references A1 — but cell C3 is completely unaffected.
Code Example 3: Jotai — Atoms and Derived State
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
// Define atoms — each is an independent piece of state
// Atoms live outside of components, similar to Zustand stores
const countAtom = atom(0);
const nameAtom = atom("Alice");
// Derived atom — automatically updates when countAtom changes
// Read-only: it computes a value from other atoms
const doubleCountAtom = atom((get) => get(countAtom) * 2);
// Derived atom combining multiple atoms
const greetingAtom = atom(
(get) => `Hello, ${get(nameAtom)}! Count is ${get(countAtom)}.`
);
// Write-only atom — defines an action without holding state
const incrementAtom = atom(
null, // no read value
(get, set) => {
// set updates the target atom, get reads any atom
set(countAtom, get(countAtom) + 1);
}
);
// Component subscribes to ONE atom — re-renders only when that atom changes
function CountDisplay() {
// useAtomValue is a read-only hook — component never triggers writes
const count = useAtomValue(countAtom);
const doubled = useAtomValue(doubleCountAtom);
return (
<div>
<p>Count: {count}</p>
<p>Doubled: {doubled}</p>
</div>
);
}
// This component only reads nameAtom — it does NOT re-render when count changes
function NameDisplay() {
const greeting = useAtomValue(greetingAtom);
return <p>{greeting}</p>;
}
function Controls() {
// useAtom gives both read and write — like useState
const [count, setCount] = useAtom(countAtom);
// useSetAtom gives only the setter — component does not re-render on value change
const increment = useSetAtom(incrementAtom);
return (
<div>
<button onClick={increment}>Increment</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
function App() {
return (
<div>
<CountDisplay />
<NameDisplay />
<Controls />
</div>
);
}
// Clicking Increment three times:
// CountDisplay output: "Count: 3" and "Doubled: 6"
// NameDisplay output: "Hello, Alice! Count is 3."
// NameDisplay re-rendered because greetingAtom depends on countAtom.
// If we had a component reading ONLY nameAtom, it would NOT re-render.
Key point: Jotai atoms are composable. Derived atoms create a dependency graph where updates propagate only to atoms and components that actually depend on the changed value. This gives you fine-grained reactivity without selectors.
Code Example 4: Jotai — Async Atoms for Data Fetching
import { atom, useAtomValue } from "jotai";
import { Suspense } from "react";
// Async atom — returns a promise, integrates with React Suspense
const userIdAtom = atom(1);
// This derived atom fetches data whenever userIdAtom changes
const userDataAtom = atom(async (get) => {
const id = get(userIdAtom);
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
if (!response.ok) throw new Error("Failed to fetch user");
return response.json();
});
// Component reads the async atom — Suspense handles the loading state
function UserProfile() {
// useAtomValue suspends until the promise resolves
const user = useAtomValue(userDataAtom);
return (
<div>
<h2>{user.name}</h2>
<p>Email: {user.email}</p>
<p>Company: {user.company.name}</p>
</div>
);
}
function UserSelector() {
const [userId, setUserId] = useAtom(userIdAtom);
return (
<select value={userId} onChange={(e) => setUserId(Number(e.target.value))}>
<option value={1}>User 1</option>
<option value={2}>User 2</option>
<option value={3}>User 3</option>
</select>
);
}
function App() {
return (
<div>
<UserSelector />
{/* Suspense boundary catches the pending promise from userDataAtom */}
<Suspense fallback={<p>Loading user...</p>}>
<UserProfile />
</Suspense>
</div>
);
}
// Selecting "User 2":
// Output: "Loading user..." appears briefly
// Then: "Leanne Graham" is replaced with "Ervin Howell"
// Only UserProfile re-renders — UserSelector is unaffected
Key point: Jotai's async atoms integrate naturally with React Suspense. The data fetching logic lives in the atom definition, and components stay clean — no loading states, no useEffect, no manual error handling in the component.
Comparison Table — Context vs Redux vs Zustand vs Jotai
This table is your interview cheat sheet. When asked "which library would you choose and why," this gives you the framework to answer precisely.
| Feature | Context + useReducer | Redux Toolkit | Zustand | Jotai |
|---|---|---|---|---|
| Bundle size | 0 KB (built-in) | ~11 KB | ~1.2 KB | ~2.4 KB |
| Boilerplate | Medium | Medium (reduced from classic) | Minimal | Minimal |
| Provider required | Yes | Yes | No | Optional |
| Fine-grained subscriptions | No (re-renders all consumers) | Yes (useSelector) | Yes (selectors) | Yes (per-atom) |
| Middleware | None | Built-in (thunk, listener) | Composable (devtools, persist, immer) | Extensions and plugins |
| DevTools | React DevTools only | Redux DevTools | Redux DevTools (via middleware) | Jotai DevTools |
| Async support | Manual (useEffect) | createAsyncThunk | Direct in actions | Async atoms + Suspense |
| Learning curve | Low | Medium | Very low | Low-medium |
| Best for | Simple, infrequent state | Large teams, complex apps | Small-to-large apps, quick setup | Derived state, composable atoms |
| Mental model | Broadcast channel | Single store + dispatched actions | External store + selectors | Reactive atom graph |
Choosing the Right Tool — A Decision Framework
Use this flowchart logic in interviews when asked about state management choices:
- Is the state local to one component? Use
useStateoruseReducer. Stop here. - Do 2-3 siblings share the state? Lift state to the parent. Stop here.
- Is the state global but changes rarely? (theme, auth, locale) Use Context. Stop here.
- Is the state global and changes frequently? Continue to step 5.
- Do you need a centralized store with strict conventions for a large team? Use Redux Toolkit.
- Do you want minimal boilerplate and a simple mental model? Use Zustand.
- Is your state highly derived, with atoms depending on other atoms? Use Jotai.
- Do you prefer mutable-style updates with proxy-based reactivity? Consider Valtio.
The honest answer for most projects: Zustand. It covers 90% of use cases with the least friction. Redux Toolkit is the right choice when team size, strict conventions, and ecosystem maturity matter more than simplicity. Jotai shines when your state graph is complex with many derived values.
Common Mistakes
Mistake 1: Using selectors incorrectly in Zustand — creating new references
// BAD: This selector creates a new array on every call
// The component re-renders on EVERY store update, not just when todos change
function TodoList() {
const todos = useStore((state) => state.todos.filter((t) => !t.completed));
// filter() returns a new array reference every time
// Zustand uses Object.is() to compare — new reference means re-render
return todos.map((t) => <Todo key={t.id} todo={t} />);
}
// GOOD: Use shallow comparison for derived arrays/objects
import { useShallow } from "zustand/react/shallow";
function TodoList() {
const todos = useStore(
useShallow((state) => state.todos.filter((t) => !t.completed))
);
// useShallow compares each element instead of the array reference
// Re-renders only when the actual items change
return todos.map((t) => <Todo key={t.id} todo={t} />);
}
Mistake 2: Creating one giant Zustand store for everything
// BAD: One massive store with unrelated domains mixed together
const useStore = create((set) => ({
// Auth state
user: null,
login: (user) => set({ user }),
logout: () => set({ user: null }),
// Cart state
items: [],
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
// Theme state
theme: "light",
toggleTheme: () => set((s) => ({ theme: s.theme === "light" ? "dark" : "light" })),
// UI state
sidebarOpen: false,
modalOpen: false,
// ... 30 more properties
}));
// GOOD: Split into domain-specific stores — Zustand makes this easy
const useAuthStore = create((set) => ({
user: null,
login: (user) => set({ user }),
logout: () => set({ user: null }),
}));
const useCartStore = create((set) => ({
items: [],
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
removeItem: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
}));
const useThemeStore = create((set) => ({
theme: "light",
toggleTheme: () => set((s) => ({ theme: s.theme === "light" ? "dark" : "light" })),
}));
// Each store is independent, testable, and easy to reason about.
// Components import only the store they need.
Mistake 3: Reaching for Jotai when the state is not actually derived
// UNNECESSARY: Using Jotai atoms for simple key-value state with no derivations
const nameAtom = atom("");
const emailAtom = atom("");
const ageAtom = atom(0);
const cityAtom = atom("");
const countryAtom = atom("");
// 15 more atoms for a user profile form...
// Each atom is independent — there is no dependency graph
// This is a worse developer experience than a single Zustand store or useState
// WHEN JOTAI SHINES: State with a dependency graph
const celsiusAtom = atom(0);
const fahrenheitAtom = atom((get) => get(celsiusAtom) * 9 / 5 + 32);
const kelvinAtom = atom((get) => get(celsiusAtom) + 273.15);
const isBoilingAtom = atom((get) => get(celsiusAtom) >= 100);
const summaryAtom = atom(
(get) => `${get(celsiusAtom)}C = ${get(fahrenheitAtom)}F (${get(isBoilingAtom) ? "boiling" : "not boiling"})`
);
// Changing celsiusAtom automatically cascades to all derived atoms.
// Only components subscribing to the specific derived atom re-render.
// THIS is the atomic model's strength — not flat, independent values.
Interview Questions
Q: Why did libraries like Zustand and Jotai emerge if Redux already existed?
Redux solved the state management problem but introduced significant boilerplate — action types, action creators, reducers, selectors, middleware configuration, and Provider setup. For many applications, the ceremony outweighed the benefit. Zustand emerged to offer the same global store concept with a fraction of the code — no providers, no reducers, just a store with functions. Jotai took a different direction entirely, introducing an atomic model where each piece of state is independent and composable. These libraries address the fact that most React applications do not need Redux's strict architecture, and developers wanted simpler tools that still provided fine-grained subscriptions and good performance.
Q: How does Zustand achieve fine-grained subscriptions without React Context?
Zustand creates a store outside of React using a vanilla JavaScript module. It uses
useSyncExternalStoreunder the hood, which is a React hook designed for subscribing to external data sources. When a component uses a selector likeuseStore(state => state.count), Zustand subscribes that component to the store and only triggers a re-render when the selected value changes (compared usingObject.is). Because there is no Context Provider, there is no context propagation overhead and no risk of re-rendering unrelated consumers.
Q: What is atomic state management, and when would you choose it over a centralized store?
Atomic state management, as implemented by Jotai, defines state as independent atoms — small, isolated pieces of state that can depend on other atoms. Instead of one store holding everything, each atom is a reactive node in a dependency graph. When an atom updates, only the atoms and components that depend on it react. I would choose atomic state when my application has complex derived state — values that are computed from other values in a graph-like pattern. For simple global state without many derivations, a centralized store like Zustand is simpler and more intuitive.
Q: When would you still choose Redux Toolkit over Zustand or Jotai?
Redux Toolkit is still the best choice for large teams working on enterprise applications that benefit from strict conventions. Redux enforces a single pattern — slices, reducers, and dispatched actions — which makes onboarding new developers easier and code reviews more consistent. Its mature ecosystem includes battle-tested middleware (RTK Query for data fetching, saga for complex async flows), extensive DevTools with time-travel debugging, and broad community support with well-documented patterns. If the project has 10+ developers, requires strict architectural conventions, or depends on RTK Query's caching and invalidation, Redux Toolkit is the right choice.
Q: Compare the mental models of Zustand and Jotai. When does each approach become awkward?
Zustand uses a centralized store mental model — one store object containing state and actions, accessed through selectors. This is intuitive and works well for most applications. It becomes awkward when you have many pieces of derived state that depend on each other in a graph, because you end up manually computing derivations inside selectors or actions. Jotai uses a bottom-up atomic mental model — small atoms that compose into larger derived atoms. This is elegant for reactive, derived state graphs but becomes awkward when you just need a flat bag of key-value state with no derivations. Using 20 independent atoms for a simple form is more verbose and harder to manage than a single Zustand store or even plain
useState.
Quick Reference — Cheat Sheet
MODERN STATE MANAGEMENT — ZUSTAND, JOTAI & ALTERNATIVES
=========================================================
ZUSTAND (Centralized Store, Minimal Boilerplate):
Create: const useStore = create((set) => ({ count: 0, inc: () => set(s => ({ count: s.count + 1 })) }))
Read (all): const { count, inc } = useStore()
Read (slice): const count = useStore(state => state.count)
Async: Direct in actions — no thunks needed
Middleware: devtools(), persist(), immer()
Provider: NONE — store lives outside React
JOTAI (Atomic State, Bottom-Up Composition):
Create: const countAtom = atom(0)
Derived: const doubleAtom = atom((get) => get(countAtom) * 2)
Read: const count = useAtomValue(countAtom)
Write: const setCount = useSetAtom(countAtom)
Read+Write: const [count, setCount] = useAtom(countAtom)
Async: atom(async (get) => fetch(...)) // works with Suspense
Provider: Optional (for scoped state or testing)
+------------------------+-----------+-----------+-----------+-----------+
| Feature | Context | Redux TK | Zustand | Jotai |
+------------------------+-----------+-----------+-----------+-----------+
| Bundle size | 0 KB | ~11 KB | ~1.2 KB | ~2.4 KB |
| Provider required | Yes | Yes | No | Optional |
| Fine-grained subs | No | Yes | Yes | Yes |
| Middleware / plugins | None | Built-in | Compose | Extensions|
| Async approach | Manual | Thunks | In-action | Atoms |
| DevTools | None | Full | Via wrap | Plugin |
| Learning curve | Low | Medium | Very Low | Low-Med |
+------------------------+-----------+-----------+-----------+-----------+
DECISION FLOWCHART:
Local state? → useState / useReducer
Siblings share? → Lift to parent
Global, changes rarely? → Context API
Global, changes often? → Zustand (most projects)
Large team, strict conventions? → Redux Toolkit
Complex derived state graph? → Jotai
Prefer mutable-style updates? → Valtio
RULES:
- Start with the simplest tool. Promote only when the pain is real.
- Zustand selectors must return stable references — use useShallow for arrays/objects.
- Split Zustand stores by domain. One mega-store is the same mistake as one mega-context.
- Jotai shines for derived state. Do not use it for flat, independent values.
- Redux Toolkit is not dead — it is the right choice for teams that need conventions.
Previous: Lesson 6.3 — Redux Toolkit — The Modern Way -> Next: Lesson 7.1 — Client-Side Routing ->
This is Lesson 6.4 of the React Interview Prep Course — 10 chapters, 42 lessons.