Introduction
Managing global state in React can become challenging as your application grows. While React’s built-in state (useState, useReducer, and context) works well for simple cases, it often becomes difficult to handle complex and deeply shared state across multiple components.
To solve this, lightweight state management libraries like Zustand and Jotai offer simple, scalable, and high-performance ways to manage global state with minimal code.
In this article, you’ll learn how Zustand and Jotai work, how to use them in real-world React applications, and how to decide which one fits your needs.
Why Not Just Use React Context?
React Context is useful but has limitations:
Causes unnecessary re-renders when state changes
Not ideal for large or deeply nested state
Harder to scale in big apps
Zustand and Jotai solve these issues with:
What Is Zustand?
Zustand is a small, fast, and scalable state management library.
Key Features
Extremely simple API
No reducers or actions required
Global store with minimal code
Selectors to prevent unnecessary re-renders
Great for complex apps
Install Zustand
npm install zustand
Creating a Store in Zustand
Example Store
import { create } from 'zustand';
const useUserStore = create((set) => ({
user: null,
setUser: (data) => set({ user: data }),
clearUser: () => set({ user: null })
}));
Using the Store in a Component
function Profile() {
const user = useUserStore((state) => state.user);
const setUser = useUserStore((state) => state.setUser);
return (
<div>
<p>User: {user?.name ?? 'No user'}</p>
<button onClick={() => setUser({ name: 'Alex' })}>Login</button>
</div>
);
}
Why Zustand Works Well
Handling Complex or Nested State with Zustand
Zustand makes complex state easy.
Example
const useCartStore = create((set) => ({
cart: [],
addItem: (item) => set((state) => ({ cart: [...state.cart, item] })),
removeItem: (id) => set((state) => ({ cart: state.cart.filter((i) => i.id !== id) }))
}));
Advantages
Async State Logic in Zustand
Example Fetching Data
const useProductStore = create((set) => ({
products: [],
fetchProducts: async () => {
const res = await fetch('/api/products');
const data = await res.json();
set({ products: data });
}
}));
Why It’s Powerful
What Is Jotai?
Jotai is a minimalistic state management library based on atoms.
Key Features
Simple and flexible
Each piece of state is an atom
Fine-grained updates (only components using an atom re-render)
Great for shared UI state, forms, dynamic UIs
Install Jotai
npm install jotai
Creating Atoms in Jotai
Example Atom
import { atom, useAtom } from 'jotai';
const countAtom = atom(0);
Using an Atom
function Counter() {
const [count, setCount] = useAtom(countAtom);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
</div>
);
}
Why Jotai Works Well
Derived Atoms (Computed State in Jotai)
Jotai allows derived state based on other atoms.
Example
const priceAtom = atom(100);
const taxAtom = atom(10);
const totalAtom = atom((get) => get(priceAtom) + get(taxAtom));
Benefits
Async Atoms in Jotai
Jotai supports async atoms easily.
Example
const userAtom = atom(async () => {
const res = await fetch('/api/user');
return await res.json();
});
Why It’s Useful
Zustand vs Jotai — Which Should You Use?
| Feature | Zustand | Jotai |
|---|
| Style | Single global store | Many small atoms |
| Best for | Complex and large apps | UI state, small to medium apps |
| Boilerplate | Minimal | Very minimal |
| Async logic | Built-in | Built-in |
| Performance | Excellent | Excellent |
| Learning curve | Very easy | Very easy |
Simple Recommendation
Use Zustand for complex logic/state-heavy apps (cart, dashboards, multi-page apps).
Use Jotai for flexible UI state, forms, filters, and small atomic states.
Best Practices for Managing Global State
Keep global state minimal
Use Zustand/Jotai only where necessary
Avoid deeply nested objects when possible
Split stores/atoms logically
Use selectors in Zustand to prevent re-renders
Use derived atoms in Jotai for computed state
Advanced Patterns in Zustand
Zustand supports powerful patterns that help manage complex application logic at scale.
1. Middleware: Persisting State to LocalStorage
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const useThemeStore = create(
persist(
(set) => ({
theme: 'light',
toggleTheme: () => set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' }))
}),
{
name: 'theme-storage'
}
)
);
2. Zustand + DevTools Integration
import { devtools } from 'zustand/middleware';
const useStore = create(devtools((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 }))
})));
3. Zustand Slices for Large Apps
const createUserSlice = (set) => ({
user: null,
login: (u) => set({ user: u })
});
const createCartSlice = (set) => ({
cart: [],
addToCart: (item) => set((s) => ({ cart: [...s.cart, item] }))
});
const useStore = create((set) => ({
...createUserSlice(set),
...createCartSlice(set)
}));
Advanced Patterns in Jotai
1. Atom Families (Dynamic Atoms)
Used for dynamic lists like form fields.
import { atomFamily } from 'jotai/utils';
const fieldAtom = atomFamily((id) => atom(`Field-${id}`));
2. Writable Derived Atoms
const firstNameAtom = atom('John');
const lastNameAtom = atom('Doe');
const fullNameAtom = atom(
(get) => `${get(firstNameAtom)} ${get(lastNameAtom)}`,
(get, set, value) => {
const [first, last] = value.split(' ');
set(firstNameAtom, first);
set(lastNameAtom, last);
}
);
3. Splitting Global State Into Logical Atoms
Avoid large objects; break state into atomic units.
Real-World Examples
1. Zustand Cart System
const useCartStore = create((set) => ({
cart: [],
add: (item) => set((s) => ({ cart: [...s.cart, item] })),
remove: (id) => set((s) => ({ cart: s.cart.filter((i) => i.id !== id) })),
total: () => useCartStore.getState().cart.reduce((t, i) => t + i.price, 0)
}));
2. Jotai Theme Toggle
const themeAtom = atom('light');
const toggleThemeAtom = atom(
(get) => get(themeAtom),
(get, set) => set(themeAtom, get(themeAtom) === 'light' ? 'dark' : 'light')
);
3. Jotai Filter State for a Product List
const searchAtom = atom('');
const categoryAtom = atom('all');
const productsAtom = atom(async () => await fetch('/api/products').then(r => r.json()));
const filteredProductsAtom = atom((get) => {
const search = get(searchAtom).toLowerCase();
const category = get(categoryAtom);
return get(productsAtom).filter((p) =>
p.name.toLowerCase().includes(search) &&
(category === 'all' || p.category === category)
);
});
Comparison Chart: Zustand vs Jotai vs Redux Toolkit vs Recoil
| Feature | Zustand | Jotai | Redux Toolkit | Recoil |
|---|
| Boilerplate | Very Low | Very Low | High | Medium |
| Learning Curve | Easy | Easy | Medium | Medium |
| Best Use Case | Large apps, complex logic | UI state, atomic state | Enterprise-scale architecture | App-wide state with relationships |
| Performance | Excellent | Excellent | Good | Very Good |
| Async Support | Built-in | Built-in | Needs Thunks/Sagas | Built-in |
| DevTools | Yes | Yes | Yes | Yes |
| State Model | Single Store | Atoms | Reducers/Actions | Atoms/Selectors |
Summary
Choose Zustand for large, logic-heavy apps.
Choose Jotai for highly dynamic UI state.
Choose Redux Toolkit for enterprise-level structure + strict patterns.
Choose Recoil for dependency-based state graph needs.
Conclusion
Managing global state in React becomes much easier with Zustand and Jotai. Both libraries are lightweight, fast, and developer-friendly, offering clean APIs for sharing and updating state across components. Zustand is ideal for larger, more complex apps, while Jotai is perfect for atomic UI state and modular architecture. With the right choice and good practices, you can maintain clean, scalable, and high-performance React applications.