React: State Management: Zustand / Redux
Last updated: 2026-08-26
Tom’s shopping cart feature was initially implemented by passing data layer by layer using
useStateandprops, which was barely functional. However, as the component tree grew deeper (App → Header → CartIcon, App → ProductList → ProductCard → AddToCartButton), modifying the shopping cart state required passing retorno de chamada functions through five layers of components—and even the slightest mistake could result in a retorno de chamada being missed or passed incorrectly. He realized: A global state management solution was needed, allowing any component to directly read and write shared state.
1. What You'll Learn
- Zustand: The Core API for Minimalist State Management
- Redux Toolkit workflow: Slice → Store → Provider → Hooks
- Selection Criteria and Applicable Scenarios for the Two Solutions
- Using Persistent Storage Middleware
- Type-Safe State Management in TypeScript
2. Conceptual Diagrams
flowchart LR
subgraph Zustand[Zustand Pattern]
A1[create API] --> B1[Store<br/>state + actions]
B1 --> C1[Component A<br/>Subscribe Now]
B1 --> C2[Component B<br/>Subscribe Now]
B1 --> C3[Component C<br/>Subscribe Now]
end
subgraph Redux[Redux Toolkit Pattern]
A2[createSlice] --> B2[Slice<br/>reducers + actions]
B2 --> C4[configureStore]
C4 --> D[Provider<br/>Wrapper Root Component]
D --> E1[useSelector<br/>Read Status]
D --> E2[useDispatch<br/>Trigger an update]
end
style A1 fill:#e1f5fe,stroke:#0288d1
style B1 fill:#fff3e0,stroke:#f57c00
style C4 fill:#e8f5e9,stroke:#388e3c
style D fill:#c8e6c9,stroke:#2e7d32
Left: Zustand—no provider, direct subscriptions, and a minimalist API. Right: Redux Toolkit—offers a complete workflow and is suitable for collaboration among large teams.
3. A Real-Life Scenario
Tom's shopping cart requires several features: an "Add to Cart" button on the product list page, a shopping cart icon in the header (displaying the number of items), a shopping cart page (displaying the product list and total price), and the ability to clear the shopping cart after placing an order. These components are spread across different levels of the component tree, and passing them using useState + props is not only cumbersome but also prone to errors.
(1) Why can't Context be used as a substitute?
React's Context does indeed prevent props from being passed down through multiple levels, but it has two serious problems:
- Performance Issue: When a context is updated, all components that consume that context will re-render, even if they are read-only. In a shopping cart scenario, the shopping cart icon will also re-render when the quantity is changed.
- No built-in action mechanism: Context only provides a standardized way to pass values; it does not provide a standardized way to update state, making it easy to end up with update logic scattered throughout the code.
Dedicated state management libraries (Zustand, Redux Toolkit) address performance issues through a selective subscription mechanism—components only listen for the state fragments that concern them, and irrelevant updates do not trigger a re-render.
| Solution | Performance | Provider | Selective Subscription | Learning Curve | Package Size |
|---|---|---|---|---|---|
| useState + props | ✅ Fast locally | None | N/A | Very low | 0 |
| Context + useReducer | ❌ Full re-render | Required | ❌ | Low | 0 |
| Status | ✅ Optional subscription | Not required | ✅ Native | Low | ~1KB |
| Redux Toolkit | ✅ useSelector | Required | ✅ | in | ~12KB |
(2) Zustand: Minimalist Approach
The core idea behind Zustand is one hook does it all: create() It defines both state and actions at the same time, and the returned hook can be used directly in any component without needing to be wrapped in a Provider.
npm install zustand
▶ Example 1: Zustand Shopping Cart Store
Output:
Subheading: "Shopping Cart". Buttons: addItem(product)}>Add to Cart, removeItem(item.id)}>Delete, Empty Cart. Text input field
import { create } from 'zustand'
// Definition store Types of(TypeScript Project Recommendations)
/*
type CartItem = { id: number; name: string; price: number; qty: number }
type CartStore = {
items: CartItem[]
total: number
addItem: (product: Omit<CartItem, 'qty'>) => void
removeItem: (id: number) => void
updateQty: (id: number, qty: number) => void
clearCart: () => void
}
*/
// Create store — state and actions They're all defined here
const useCartStore = create((set, get) => ({
// --- state ---
items: [],
total: 0,
// --- actions ---
addItem: (product) => set((state) => {
const existing = state.items.find(i => i.id === product.id)
const newItems = existing
? state.items.map(i =>
i.id === product.id ? { ...i, qty: i.qty + 1 } : i
)
: [...state.items, { ...product, qty: 1 }]
return {
items: newItems,
total: newItems.reduce((sum, i) => sum + i.price * i.qty, 0)
}
}),
removeItem: (id) => set((state) => {
const newItems = state.items.filter(i => i.id !== id)
return {
items: newItems,
total: newItems.reduce((sum, i) => sum + i.price * i.qty, 0)
}
}),
updateQty: (id, qty) => set((state) => {
const newItems = qty <= 0
? state.items.filter(i => i.id !== id)
: state.items.map(i => i.id === id ? { ...i, qty } : i)
return {
items: newItems,
total: newItems.reduce((sum, i) => sum + i.price * i.qty, 0)
}
}),
clearCart: () => set({ items: [], total: 0 })
}))
// Use in any component
function CartIcon() {
// Subscribe to items.length only,The page will only be re-rendered when the shopping cart list changes.
const itemCount = useCartStore(state => state.items.length)
return <span className="cart-badge">Shopping Cart ({itemCount})</span>
}
function ProductCard({ product }) {
const addItem = useCartStore(state => state.addItem)
return (
<div>
<h3>{product.name}</h3>
<p>${product.price}</p>
<button onClick={() => addItem(product)}>Add to Cart</button>
</div>
)
}
function CartPage() {
const { items, total, updateQty, removeItem, clearCart } = useCartStore()
if (items.length === 0) return <p>Your shopping cart is empty</p>
return (
<div>
<h2>Shopping Cart</h2>
{items.map(item => (
<div key={item.id}>
<span>{item.name}</span>
<input
type="number"
value={item.qty}
min="0"
onChange={e => updateQty(item.id, Number(e.target.value))}
/>
<span>${item.price * item.qty}</span>
<button onClick={() => removeItem(item.id)}>Delete</button>
</div>
))}
<hr />
<p><strong>Total:${total}</strong></p>
<button onClick={clearCart}>Empty Cart</button>
</div>
)
}
Output:
Zustand store: create((set) => ({ count: 0, increment: () => set(s => ({ count: s.count + 1 })) }))
Key Features of Zustand:
- Zero Provider: No need to wrap
<Provider>; simply call the hook directly within any component - Selective child subscription:
useCartStore(state => state.items.length)triggers a re-render only whenitems.lengthchanges, offering better performance than Context - set + get:
setupdates the state;getreads the current state (useful for reading other state values within an action)
▶ Example 2: Zustand Persistence Middleware
Output:
Subheading: "Shopping Cart". Displays: "state.items.length) return". Buttons: addItem(product)}>Add to Cart, removeItem(item.id)}>Delete, Empty Cart. Input type: number
Tom wants users to be able to refresh the page without losing their shopping cart data. The persist middleware in Zustand can automatically sync the store to localStorage.
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
// persist Middleware Package store Definition
const useCartStore = create(
persist(
(set, get) => ({
items: [],
total: 0,
addItem: (product) => set((state) => {
const existing = state.items.find(i => i.id === product.id)
const newItems = existing
? state.items.map(i =>
i.id === product.id ? { ...i, qty: i.qty + 1 } : i
)
: [...state.items, { ...product, qty: 1 }]
return {
items: newItems,
total: newItems.reduce((sum, i) => sum + i.price * i.qty, 0)
}
}),
removeItem: (id) => set((state) => {
const newItems = state.items.filter(i => i.id !== id)
return {
items: newItems,
total: newItems.reduce((sum, i) => sum + i.price * i.qty, 0)
}
}),
clearCart: () => set({ items: [], total: 0 })
}),
{
name: 'cart-storage', // localStorage 's key
// Store all fields by default,That's fine, too. partialize Select which fields to store
// partialize: (state) => ({ items: state.items }),
}
)
)
// The way it works remains exactly the same
function CartIcon() {
const itemCount = useCartStore(state => state.items.length)
return <span>Shopping Cart ({itemCount})</span>
}
// localStorage Data Format Stored in the System:
// key: 'cart-storage'
// value: '{"state":{"items":[...],"total":99},"version":0}'
How Persistence Middleware Works: Any state changes are automatically synchronized to localStorage, and data is automatically restored from localStorage when the application starts. When the user refreshes the page, the shopping cart state is fully preserved.
▶ Example 3: Debugging in Zustand DevTools
Output:
Zustand DevTools: view state change log, action history, and time-travel debug. Install Redux DevTools extension to inspect store
During the development phase, Tom wanted to view the history of state changes, similar to Redux DevTools. Zustand’s devtools middleware integrates seamlessly with browser DevTools extensions.
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
// Using Multiple Middleware Components Simultaneously:Combine using a nested approach
const useStore = create(
devtools(
persist(
(set) => ({
count: 0,
increment: () => set(state => ({ count: state.count + 1 })),
decrement: () => set(state => ({ count: state.count - 1 })),
}),
{ name: 'app-storage' }
),
{ name: 'AppStore' } // DevTools As shown in store Name
)
)
// Open in a browser Redux DevTools Expand
// As you can see "AppStore" All items under this tag state Change Log
// Supports time-travel debugging(Jump to any historical state)
Output:
Zustand DevTools: view state change log, action history, and time-travel debug. Install Redux DevTools extension to inspect store
Features of the Zustand middleware system: Middleware is executed sequentially from the outer layer to the inner layer through nested function combinations. devtools(persist(...)) indicates that DevTools is connected first, followed by persistence processing. Multiple middleware components can be flexibly combined to meet different needs. For example, you can add the immer middleware to enable variable syntax, or create custom middleware to log each action.
4. Redux Toolkit: An Enterprise-Grade Solution
When a project grows to the point where multiple teams are collaborating, the flexibility of Zustand can actually become a problem—developers may write actions in inconsistent styles. Redux Toolkit provides a standardized state management workflow that enforces a consistent pattern across the team.
npm install @reduxjs/toolkit react-redux
▶ Example 4: The Complete Redux Toolkit Workflow
Output:
Subheading: "Counter:{value}". Buttons: dispatch(increment())}>+1, dispatch(decrement())}>-1, dispatch(incrementByAmount(5))}>+5, dispatch(reset())}>Reset. List items: {h}
import { createSlice, configureStore } from '@reduxjs/toolkit'
import { Provider, useSelector, useDispatch } from 'react-redux'
// ========== 1. Create Slice(Definition state + reducers)==========
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0, history: [] },
reducers: {
increment: (state) => {
state.value += 1
state.history.push(`+1 → ${state.value}`)
},
decrement: (state) => {
state.value -= 1
state.history.push(`-1 → ${state.value}`)
},
incrementByAmount: (state, action) => {
state.value += action.payload
state.history.push(`+${action.payload} → ${state.value}`)
},
reset: (state) => {
state.value = 0
state.history = []
}
}
})
// Automatically generated action creators
export const { increment, decrement, incrementByAmount, reset } = counterSlice.actions
// ========== 2. Layout Store ==========
const store = configureStore({
reducer: {
counter: counterSlice.reducer,
// More can be added later. slice:
// cart: cartSlice.reducer,
// user: userSlice.reducer,
}
})
// ========== 3. Provider Inject ==========
function App() {
return (
<Provider store={store}>
<Counter />
</Provider>
)
}
// ========== 4. Used in components ==========
function Counter() {
// Selective Reading of Characters
const value = useSelector(state => state.counter.value)
const history = useSelector(state => state.counter.history)
const dispatch = useDispatch()
return (
<div>
<h2>Counter:{value}</h2>
<div>
<button onClick={() => dispatch(increment())}>+1</button>
<button onClick={() => dispatch(decrement())}>-1</button>
<button onClick={() => dispatch(incrementByAmount(5))}>+5</button>
<button onClick={() => dispatch(reset())}>Reset</button>
</div>
<div>
<h3>Transaction History</h3>
<ul>
{history.slice(-5).map((h, i) => (
<li key={i}>{h}</li>
))}
</ul>
</div>
</div>
)
}
Output:
Redux store: createSlice defines actions (increment, decrement). configureStore creates store. useSelector reads state, useDispatch sends actions.
Key Concepts of Redux Toolkit:
| Concept | Function | Analogy |
|---|---|---|
createSlice |
Create a set of related states and reducers | File Drawer: Organize and Store |
configureStore |
Combine all slices to create a global store | File Cabinet: Centralized Management |
Provider |
Injecting the store into the React component tree | Power cord: Plug it in |
useSelector |
Read a state fragment from the store | Open the drawer to retrieve a file |
useDispatch |
Trigger an action to update the status | Issue a command for the administrator to take action |
▶ Example 5: Asynchronous Operations in Redux Toolkit
Output:
Buttons: dispatch(fetchuser(userid))}>load users
State updates in real-world projects often involve asynchronous logic (such as logging in or retrieving user information). Redux Toolkit includes built-in support for the lifecycle stages of asynchronous operations (pending, fulfilled, and rejected).
import { createSlice, configureStore, createAsyncThunk } from '@reduxjs/toolkit'
import { Provider, useSelector, useDispatch } from 'react-redux'
// 1. Defining Asynchronous thunk(Automatically Generated pending/fulfilled/rejected Three types action)
const fetchUser = createAsyncThunk(
'user/fetchUser', // action Type prefix
async (userId, { rejectWithValue }) => {
try {
const response = await fetch(`/api/users/${userId}`)
if (!response.ok) throw new Error('Failed to retrieve the user')
return await response.json()
} catch (err) {
return rejectWithValue(err.message)
}
}
)
// 2. Create slice
const userSlice = createSlice({
name: 'user',
initialState: {
data: null,
loading: false,
error: null
},
reducers: {
clearUser: (state) => {
state.data = null
state.error = null
}
},
// Handling Asynchronous Operations thunk The Three States of
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = false
state.data = action.payload
})
.addCase(fetchUser.rejected, (state, action) => {
state.loading = false
state.error = action.payload
})
}
})
export const { clearUser } = userSlice.actions
// 3. Layout store
const store = configureStore({
reducer: { user: userSlice.reducer }
})
// 4. Used in components
function UserProfile({ userId }) {
const dispatch = useDispatch()
const { data, loading, error } = useSelector(state => state.user)
return (
<div>
<button onClick={() => dispatch(fetchUser(userId))}>Load Users</button>
{loading && <p>Loading......</p>}
{error && <p>Error:{error}</p>}
{data && <p>{data.name} — {data.email}</p>}
</div>
)
}
createAsyncThunk automatically handles the three states: When a request is initiated, it dispatches pending → Upon success, it dispatches fulfilled (carrying the data) → Upon failure, it dispatches rejected (carrying the error message). Developers only need to define how the state changes for each condition within extraReducers. Compared to using useEffect and fetch directly, the advantages of this pattern include: automatic tracking of request status, actions that can be logged by DevTools, and the ability for multiple components to subscribe to the results of the same thunk.
5. Model Comparison
| Dimension | State | Redux Toolkit |
|---|---|---|
| Code volume | Very little—just one create() function |
Moderate—the full suite of slice, store, and Provider |
| Learning Curve | Low (10 minutes to get started) | Medium (requires understanding of reducers, actions, dispatch, and thunks) |
| Provider | Not required | Must be a Provider package |
| Selective Subscription | Native Support, Selector Functions | useSelector + Shallow Comparison |
| TypeScript | Native-friendly | Good, with type inference |
| Middleware | persist / devtools / immer, etc. | Built-in thunk + extensible saga/epic |
| Asynchronous processing | Implemented manually in the action | Built-in with createAsyncThunk |
| Package Size | ~1KB | ~12KB |
| Use Cases | Small and medium-sized applications, state sharing between components | Large applications, complex data flows, cross-team collaboration |
| DevTools | Supported (requires middleware) | Native support, time-travel debugging |
Selection Tips:
- Small projects / prototypes / personal projects → Zustand. Less code, lower mental load, and just enough.
- Large projects / Multiple teams / Strict standards required → Redux Toolkit. Standardized processes ensure consistent coding styles across different teams.
- Using a combination of both is also a common approach: use Zustand for global state shared across pages, and Redux Toolkit for complex business modules.
❓ FAQ
createSlice automatically generates action types and action creators, configureStore automatically integrates middleware and DevTools, and createAsyncThunk simplifies asynchronous workflows. This reduces the amount of code by about 60%.Zustand store be used outside of components?useCartStore.getState() Retrieve the current state from anywhere, useCartStore.setState(...) update the state from anywhere. This is very useful for accessing the state in route guards, interceptors, and WebSocket callbacks.useReducer doesn’t meet your needs?useReducer is suitable for complex state logic within a single component or in a localized scope. You should only introduce Zustand or Redux Toolkit when state needs to be shared across multiple unrelated components, when it needs to be persisted, or when you require middleware. Don’t use them just for the sake of using them.set function and React’s setState?set performs shallow merging by default (similar to class components’ setState), while Redux Toolkit’s reducers, based on Immer, perform deep, immutable updates. Zustand can also be used with the immer middleware to achieve mutable update syntax similar to Redux Toolkit. Both approaches have their pros and cons in terms of usage; choose based on your team’s preferences.📖 Summary
- Zustand is known for its minimalist API:
create(set => ({ state, actions })), zero providers, and selective sub-subscriptions - Persistence Middleware
persistAutomatically synchronize state to localStorage with a single line of code - The devtools middleware integrates Zustand with Redux DevTools, enabling time-travel debugging
- Redux Toolkit provides standardized workflow:createSlice → configureStore → Provider → useSelector/useDispatch
createAsyncThunkautomatically manages the three states of asynchronous operations (pending, fulfilled, and rejected)- For small projects, we recommend Zustand; for large projects, we recommend Redux Toolkit. You can use them together.
- Middleware systems (Zustand’s nested functions vs. Redux Toolkit’s builder pattern) provide flexible extensibility
- Zustand is suitable for 80% of state-sharing scenarios, while Redux Toolkit is suitable for complex business modules that require strict specifications.
- Regardless of which option you choose, we recommend finalizing your state management strategy early in the project to avoid the migration costs associated with switching midway through.
📝 Exercises
- Create a Todo Store using Zustand: It should support adding tasks, changing the completion status, deleting tasks, clearing completed tasks, and counting the number of uncompleted tasks. Use it in both the list component and the statistics component to verify the effectiveness of selective subscription.
- Add the
persistmiddleware to Todo Store to verify whether the data is retained after refreshing the page. - Implement a user management module using Redux Toolkit: Use
createAsyncThunkto simulate fetching a list of users (with a 1-second delay before returning data), manage the three states (loading, error, and data), and display the loading state, error state, and data list in the component.