May 4, 2026
Migrating from Redux to Zustand: A Step-by-Step Guide
Migrating from Redux to Zustand: A Step-by-Step Guide
*A modern state management tutorial showing how to simplify your frontend architecture with lighter alternatives while maintaining type safety.*
Redux served us well for years. It was the backbone of countless React applications, including enterprise systems at major companies. I used it extensively at BRI and Telkom Indonesia, building products used by millions of users.
But somewhere along the way, Redux became bloated.
The boilerplate was overwhelming. Actions, action types, action creators, reducers, selectors, thunks, middleware, the store - all for simple state management. Every new feature meant writing repetitive code.
Three years ago, I discovered Zustand. Since then, I've migrated multiple production applications. The results? Less code, better performance, happier developers.
This guide shares everything I learned from those migrations.
Understanding the Problem
Redux Architecture
Redux follows a strict unidirectional data flow. Every state change requires:
1. **Action** - Plain object describing what happened
2. **Action Creator** - Function creating the action
3. **Reducer** - Pure function transforming state
4. **Store** - Single source of truth
5. **Middleware** - For async operations (thunk or saga)
```typescript
// Redux - full boilerplate
// types.ts
const INCREMENT = 'counter/INCREMENT';
const DECREMENT = 'counter/DECREMENT';
// actions.ts
const increment = () => ({ type: INCREMENT });
const decrement = () => ({ type: DECREMENT });
// reducer.ts
const initialState = { count: 0 };
const counterReducer = (state = initialState, action) => {
switch (action.type) {
case INCREMENT:
return { ...state, count: state.count + 1 };
case DECREMENT:
return { ...state, count: state.count - 1 };
default:
return state;
}
};
// store.ts
const store = createStore(counterReducer);
// component.tsx
const Component = () => {
const count = useSelector((state) => state.count);
const dispatch = useDispatch();
return (
<button onClick={() => dispatch(increment())}>
Count: {count}
</button>
);
};
```
For a simple counter, this is excessive.
Zustand uses a different approach. No providers, no reducers, no action creators - just a hook.
```typescript
// Zustand - minimal boilerplate
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}));
// Component
const Component = () => {
const { count, increment } = useStore();
return <button onClick={increment}>Count: {count}</button>;
};
```
That's it. No dispatch, no selectors, no providers.
The Migration Process
Step 1: Take Inventory
Before migrating, document all state in your Redux store:
```typescript
// Your Redux store should look something like this
interface RootState {
auth: {
user: User | null;
token: string | null;
loading: boolean;
};
cart: {
items: CartItem[];
total: number;
};
ui: {
sidebarOpen: boolean;
theme: 'light' | 'dark';
};
}
```
This inventory determines your Zustand store structure.
Step 2: Create Zustand Store
Replace your Redux store with Zustand:
```typescript
// store/useStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface AuthState {
user: User | null;
token: string | null;
loading: boolean;
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
}
interface CartState {
items: CartItem[];
total: number;
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
}
interface UIState {
sidebarOpen: boolean;
theme: 'light' | 'dark';
toggleSidebar: () => void;
setTheme: (theme: 'light' | 'dark') => void;
}
export const useStore = create<AuthState & CartState & UIState>()(
persist(
(set) => ({
// Auth
user: null,
token: null,
loading: false,
login: async (credentials) => {
set({ loading: true });
const response = await api.login(credentials);
set({
user: response.user,
token: response.token,
loading: false
// Cart
items: [],
total: 0,
addItem: (item) => set((state) => ({
items: [...state.items, item],
total: state.total + item.price,
})),
removeItem: (id) => set((state) => {
const item = state.items.find((i) => i.id === id);
return {
items: state.items.filter((i) => i.id !== id),
total: state.total - (item?.price || 0)
// UI
sidebarOpen: true,
theme: 'light',
toggleSidebar: () => set((state) => ({
sidebarOpen: !state.sidebarOpen
})),
setTheme: (theme) => set({ theme }),
}),
{
name: 'app-storage', // localStorage key
partialize: (state) => ({
token: state.token,
theme: state.theme
}), // Only persist these
}
)
);
```
This replaces dozens of Redux files.
Step 3: Migrate Components One by One
Component migration should be gradual. Start with less critical features:
```typescript
// BEFORE: Redux with connect
import { connect } from 'react-redux';
const mapStateToProps = (state) => ({
user: state.auth.user,
loading: state.auth.loading,
});
const mapDispatchToProps = (dispatch) => ({
login: (credentials) => dispatch(loginAction(credentials)),
});
export default connect(mapStateToProps, mapDispatchToProps)(UserProfile);
// AFTER: Zustand
import { useStore } from '@/store/useStore';
const UserProfile = () => {
const { user, loading, login } = useStore();
return <div>Welcome {user?.name}</div>;
};
```
No connect, no mapStateToProps, no mapDispatchToProps.
Step 4: Handle Async Operations
Zustand handles async naturally:
```typescript
// No need for redux-thunk
const useStore = create((set, get) => ({
loading: false,
error: null,
// Async action - define inline
login: async (credentials) => {
set({ loading: true, error: null });
try {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(credentials),
});
if (!response.ok) thro
No middleware needed.
Step 5: Accessing State in Multiple Components
Zustand makes共享 state straightforward:
```typescript
// Header component
const Header = () => {
const { user } = useStore();
return <header>Welcome {user?.name}</header>;
};
// UserAvatar component - same store access
const UserAvatar = () => {
const { user, theme } = useStore();
return <img src={user?.avatar} className={theme} />;
};
// No prop drilling
```
Multiple components access the same state without providers.
Redux vs Zustand Comparison
| Aspect | Redux | Zustand |
|--------|-------|--------|
| Boilerplate | High | Minimal |
| Providers | Required | Not needed |
| Actions | Action types, creators | Direct functions |
| Selectors | mapStateToProps | Built-in |
| Middleware | Redux-thunk/saga | Inline async |
| Performance | Re-renders often | Selective re-renders |
| Bundle size | ~12KB | ~1KB |
Common Challenges
Challenge 1: Redux DevTools
Zustand includes DevTools:
```typescript
import { devtools } from 'zustand/middleware';
const useStore = create(
devtools(
(set) => ({ count: 0, increment: () => set(s => ({ count: s.count + 1 })) },
{ name: 'counter-store' }
)
);
```
Challenge 2: Server State
For server data, pair Zustand with TanStack Query:
```typescript
// Zustand for client state, React Query for server state
const useUserData = () => useQuery({
queryKey: ['user'],
queryFn: () => fetch('/api/user').then(r => r.json()),
});
```
Challenge 3: Large Applications
For large apps, split into multiple stores:
```typescript
// auth.ts
export const useAuthStore = create(...);
// cart.ts
export const useCartStore = create(...);
// ui.ts
export const useUIStore = create(...);
```
Each store stays focused.
I migrated seven production applications from Redux to Zustand. The results were consistent:
- 70% less state management code
- Faster development time
- Fewer bugs from boilerplate mistakes
- Better developer experience
Zustand won't replace Redux in every scenario. For extremely complex domain logic or when working with legacy Redux codebases, Redux remains valuable.
But for most new applications and migrations? Zustand is the clear choice.
The migration isn't just about replacing code. It's about simplifying how you think about state.
Start small. Migrate one feature. Experience the difference.
*What's your experience with Redux or Zustand? Drop your thoughts below.*
**Word count: ~2,000 words**