Cách migrate từ Redux sang Zustand: hướng dẫn từng bước
Zustand v5 nhẹ hơn Redux Toolkit + react-redux 34×. Hướng dẫn 6 bước: nhân bản slice, thay component, xóa Redux, xử lý middleware thunk/saga và DevTools.
Bởi Ethan
2.386 từ · 12 phút đọc
Với các codebase nhỏ đến vừa — khoảng 8 slice trở xuống, không dùng redux-saga, không có server-state orchestration phức tạp — migration này xứng đáng thực hiện. Zustand v5.0.14 (ổn định từ ngày 28 tháng 5 năm 2026) chỉ nặng 0.5 KB gzip so với 13.3 KB của Redux Toolkit cộng 3.7 KB của react-redux — theo Bundlephobia tại các phiên bản được ghim này. Zustand bỏ hoàn toàn Provider boilerplate và gom state lẫn action vào một file duy nhất. Lợi ích đó đổi chiều khi surface Redux của bạn quá lớn: app 30 slice với saga middleware thì đây là việc viết lại, không phải refactor.
Khi nào không nên migrate
Chạy qua checklist này trước khi sửa bất kỳ dòng code nào:
- Nhóm đông (6+ kỹ sư làm việc với state hàng ngày). Pattern slice của RTK ép buộc cấu trúc nhất quán mà Zustand không có. Thiếu đó, mỗi người viết store theo kiểu riêng và code review trở nên chậm hơn.
- Bạn phụ thuộc vào time-travel debugging. Tính năng replay action của Redux DevTools thực sự hữu ích với một số loại bug nhất định. DevTools middleware của Zustand chỉ hiển thị snapshot state — không phải lịch sử action có thể tua lại.
- Bạn đang dùng redux-saga. Saga dùng để diễn đạt các async flow phức tạp (cancellation, race condition, forking) mà Zustand không có tương đương trực tiếp. Thay thế chúng thì làm được, nhưng cần thiết kế lại từng saga riêng lẻ — không phải swap máy móc.
- SSR hydration phức tạp. Pattern
configureStore+preloadedStatecủa RTK đã được kiểm chứng kỹ với Next.js và Remix. Hydration per-store của Zustand chưa được kiểm chứng nhiều ở quy mô lớn. - Codebase của bạn đang chạy tốt. Không có user nào phàn nàn về bundle size. Test qua hết. Team quen với pattern hiện tại. Viết lại state management không tạo ra giá trị nào mà user cảm nhận được, nhưng rủi ro thì có thật.
Nếu không trường hợp nào ở trên áp dụng, hãy tiếp tục.
Mô hình tư duy của Zustand trong 30 giây
Zustand không phải là cách tổ chức khác cho cùng cỗ máy Redux. Nó bỏ hẳn kiến trúc Redux: không còn reducer, không action, không dispatch.
Slice counter với Redux Toolkit:
// store/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: (state) => { state.value += 1 },
decrement: (state) => { state.value -= 1 },
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload
},
},
})
export const { increment, decrement, incrementByAmount } = counterSlice.actions
export default counterSlice.reducer
Phiên bản tương đương với Zustand (tài liệu chính thức):
// store/useCounter.ts
import { create } from 'zustand'
interface CounterStore {
value: number
increment: () => void
decrement: () => void
incrementByAmount: (amount: number) => void
}
export const useCounter = create<CounterStore>((set) => ({
value: 0,
increment: () => set((state) => ({ value: state.value + 1 })),
decrement: () => set((state) => ({ value: state.value - 1 })),
incrementByAmount: (amount) => set((state) => ({ value: state.value + amount })),
}))
Một file duy nhất. Không Provider. Không export action creator. Component import hook trực tiếp. Đó là mô hình xuyên suốt bài hướng dẫn này.
Bước 1: Cài Zustand, giữ Redux chạy song song
Chưa xóa Redux vội. Chạy song song cả hai trong quá trình migration — đó chính là lý do của cách làm từng slice một.
npm install zustand
Zustand v5.0.14 không có runtime dependency nào. Chỉ cần lệnh này là xong bước cài đặt.
Lỗi thường gặp: Kiểm tra đã cài v5.x chứ không phải major thấp hơn. Chạy npm list zustand để xác nhận — Zustand v5 yêu cầu Node >=12.20.0 theo trường engines.
Bước 2: Nhân bản một slice thành Zustand store
Chọn Redux slice nhỏ nhất, ít phụ thuộc nhất. Tạo Zustand store cạnh nó với cùng shape.
Giả sử slice nhỏ nhất là uiSlice, quản lý trạng thái đóng/mở sidebar:
// store/uiSlice.ts — Redux, don't delete yet
import { createSlice } from '@reduxjs/toolkit'
const uiSlice = createSlice({
name: 'ui',
initialState: { sidebarOpen: false },
reducers: {
openSidebar: (state) => { state.sidebarOpen = true },
closeSidebar: (state) => { state.sidebarOpen = false },
toggleSidebar: (state) => { state.sidebarOpen = !state.sidebarOpen },
},
})
export const { openSidebar, closeSidebar, toggleSidebar } = uiSlice.actions
export default uiSlice.reducer
// store/useUI.ts — Zustand, new file
import { create } from 'zustand'
interface UIStore {
sidebarOpen: boolean
openSidebar: () => void
closeSidebar: () => void
toggleSidebar: () => void
}
export const useUI = create<UIStore>((set) => ({
sidebarOpen: false,
openSidebar: () => set({ sidebarOpen: true }),
closeSidebar: () => set({ sidebarOpen: false }),
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
}))
Không xóa Redux slice. Không sửa component nào. Không đụng đến Provider. Ở bước này bạn chỉ thêm file.
Lỗi thường gặp: Nếu bạn định lấy initial state của Zustand store từ Redux selector thì dừng lại — bạn sẽ tạo ra bug hai nguồn dữ liệu. Zustand store khởi tạo từ đầu. Bất kỳ dữ liệu nào cần khi khởi động sẽ đi qua đường bình thường (API call, URL params, localStorage) sau khi bạn migrate component.
Bước 3: Thay thế component từng slice một
Tìm mọi component đọc từ hoặc dispatch tới Redux slice vừa nhân bản. Thay toàn bộ bằng Zustand hook.
Trước (Redux):
// Sidebar.tsx
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from './store'
import { toggleSidebar } from './store/uiSlice'
function Sidebar() {
const dispatch = useDispatch()
const sidebarOpen = useSelector((state: RootState) => state.ui.sidebarOpen)
return (
<aside className={sidebarOpen ? 'open' : 'closed'}>
<button onClick={() => dispatch(toggleSidebar())}>Toggle</button>
</aside>
)
}
Sau (Zustand):
// Sidebar.tsx
import { useUI } from './store/useUI'
function Sidebar() {
const { sidebarOpen, toggleSidebar } = useUI()
return (
<aside className={sidebarOpen ? 'open' : 'closed'}>
<button onClick={toggleSidebar}>Toggle</button>
</aside>
)
}
Làm vậy cho mọi component liên quan đến slice đó. Chạy test sau mỗi lần swap component. Không chuyển sang slice tiếp theo cho đến khi test qua hết.
Lỗi thường gặp: Destructuring object từ Zustand sẽ kích hoạt re-render mỗi khi bất kỳ giá trị nào trong đó thay đổi theo reference. Trong ví dụ này, toggleSidebar là function reference ổn định nên không gây re-render thừa. Khi bạn destructure nhiều giá trị state (không phải action) có thể thay đổi độc lập, hãy dùng useShallow — được giải thích ở phần Lưu ý bên dưới.
Bước 4: Xóa Redux sau khi migrate xong slice cuối
Khi toàn bộ slice đã có Zustand store và mọi component đều dùng Zustand hook, xóa Redux:
npm uninstall @reduxjs/toolkit react-redux
Tiếp theo:
- Xóa
<Provider store={store}>khỏi app root - Xóa
store/index.tscùng các type exportRootState,AppDispatch - Xóa các file Redux slice
- Xóa mọi import
useSelectorhoặcuseDispatchcòn sót
Chạy toàn bộ test suite. Mở bundle analyzer — bạn sẽ thấy vendor chunk giảm khoảng 16 KB gzip: bỏ [email protected] (13.3 KB) và [email protected] (3.7 KB), thêm [email protected] (0.5 KB).
Lỗi thường gặp: TypeScript vẫn import từ file đã xóa? Kiểm tra barrel re-export (index.ts re-export action của slice). Xóa luôn. Compiler sẽ chỉ chính xác còn sót gì.
Bước 5: Xử lý middleware — thunk và saga
Thunk
createAsyncThunk của RTK chuyển thẳng thành async function nội tuyến trong Zustand. Không cần middleware:
// Before — RTK async thunk
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
export const fetchUser = createAsyncThunk('user/fetch', async (id: string) => {
const res = await fetch(`/api/users/${id}`)
return res.json()
})
const userSlice = createSlice({
name: 'user',
initialState: { data: null, loading: false, error: null as string | null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => { state.loading = true })
.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = false
state.data = action.payload
})
.addCase(fetchUser.rejected, (state, action) => {
state.loading = false
state.error = action.error.message ?? 'Unknown error'
})
},
})
// After — Zustand async action
import { create } from 'zustand'
interface UserStore {
data: User | null
loading: boolean
error: string | null
fetchUser: (id: string) => Promise<void>
}
export const useUser = create<UserStore>((set) => ({
data: null,
loading: false,
error: null,
fetchUser: async (id) => {
set({ loading: true, error: null })
try {
const res = await fetch(`/api/users/${id}`)
set({ data: await res.json(), loading: false })
} catch (err) {
set({ error: (err as Error).message, loading: false })
}
},
}))
Saga
redux-saga không có tương đương trong Zustand. Với các saga đơn giản chỉ gọi API rồi cập nhật state — chuyển thành async function nội tuyến như trên. Với saga phức tạp dùng takeLatest, race, channel, hoặc fork, hãy cân nhắc thay phần server-state bằng TanStack Query và giữ Zustand thuần cho UI state. Đừng cố nhân bản saga orchestration trong Zustand store; đó là đi ngược lại thiết kế của thư viện.
Bước 6: Kết nối DevTools
Zustand DevTools dùng cùng extension @redux-devtools/extension bạn đã có. Middleware đi kèm sẵn với Zustand:
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
interface CounterStore {
value: number
increment: () => void
decrement: () => void
}
export const useCounter = create<CounterStore>()(
devtools(
(set) => ({
value: 0,
increment: () => set(
(state) => ({ value: state.value + 1 }),
false,
'counter/increment'
),
decrement: () => set(
(state) => ({ value: state.value - 1 }),
false,
'counter/decrement'
),
}),
{ name: 'Counter Store' }
)
)
Tham số thứ ba của set() — 'counter/increment' — là nhãn action hiển thị trong DevTools. Thiếu nó, mọi thay đổi state đều hiện là anonymous. Hãy đặt tên cho action của bạn.
Thứ bạn mất đi: time-travel. Redux DevTools cho phép bạn lùi từng bước qua các action và replay state. DevTools của Zustand chỉ hiển thị snapshot state theo từng action có nhãn, không phải log action có thể replay. Nếu time-travel thực sự quan trọng với quy trình làm việc của nhóm, đây là điểm thiếu hụt đáng kể.
Lưu ý
1. Object selector gây re-render thừa — dùng useShallow
Zustand re-render component khi giá trị được select thay đổi theo reference. Selector cho primitive (state.value) hoạt động bình thường. Selector cho object hoặc array cần useShallow:
// Gây re-render mỗi lần state cập nhật — không tốt cho object selector
const { value, label } = useCounter()
// Chỉ re-render khi value hoặc label thay đổi
import { useShallow } from 'zustand/react/shallow'
const { value, label } = useCounter(
useShallow((state) => ({ value: state.value, label: state.label }))
)
Nguồn: Zustand docs — prevent re-renders with useShallow, discussion #3103.
Action (function) do create() trả về là stable reference — bản thân chúng không gây re-render thừa. useShallow chủ yếu cần khi bạn destructure nhiều giá trị state có thể thay đổi độc lập.
2. Derived state — selector function hoặc proxy-memoize
Zustand không có tương đương sẵn cho createSelector của Redux. Với giá trị derived đơn giản, truyền selector function trực tiếp:
// Chỉ tính lại khi phần state liên quan thay đổi
const totalPrice = useCart(
(state) => state.items.reduce((sum, item) => sum + item.price * item.qty, 0)
)
Với các tính toán tốn kém trên mảng lớn, dùng proxy-memoize:
import memoize from 'proxy-memoize'
const selectTotalPrice = memoize((state: CartStore) =>
state.items.reduce((sum, item) => sum + item.price * item.qty, 0)
)
const totalPrice = useCart(selectTotalPrice)
proxy-memoize cache dựa trên phần nào của state object thực sự được truy cập. Nó xử lý object lồng nhau chính xác mà không cần dependency array thủ công như createSelector.
3. Testing — pattern reset store
Không có Redux Provider, cũng không có wrapper renderWithProviders để reset store giữa các test. Mặc định, Zustand store giữ nguyên state xuyên suốt các test case trong cùng process. Khắc phục bằng mock tự reset sau mỗi test.
Với Jest, tạo __mocks__/zustand.ts:
// __mocks__/zustand.ts (Jest)
import * as zustand from 'zustand'
import { act } from '@testing-library/react'
const { create: actualCreate } = jest.requireActual<typeof zustand>('zustand')
const storeResetFns = new Set<() => void>()
const create = (<T>(stateCreator: zustand.StateCreator<T>) => {
const store = actualCreate(stateCreator)
const initialState = store.getInitialState()
storeResetFns.add(() => store.setState(initialState, true))
return store
}) as typeof zustand.create
afterEach(() => act(() => storeResetFns.forEach((resetFn) => resetFn())))
export { create }
export * from 'zustand'
Với Vitest, importActual là async:
// __mocks__/zustand.ts (Vitest)
import * as zustand from 'zustand'
import { act } from '@testing-library/react'
import { vi } from 'vitest'
const { create: actualCreate } = await vi.importActual<typeof zustand>('zustand')
const storeResetFns = new Set<() => void>()
const create = (<T>(stateCreator: zustand.StateCreator<T>) => {
const store = actualCreate(stateCreator)
const initialState = store.getInitialState()
storeResetFns.add(() => store.setState(initialState, true))
return store
}) as typeof zustand.create
afterEach(() => act(() => storeResetFns.forEach((resetFn) => resetFn())))
export { create }
export * from 'zustand'
Nguồn: Hướng dẫn testing của Zustand. Sự khác biệt giữa Jest và Vitest — jest.requireActual so với await vi.importActual — là điểm thất bại phổ biến nhất khi copy pattern này giữa các project.
Kết luận
Hãy migrate khi codebase Redux của bạn đủ nhỏ để việc viết lại tốn một tuần, không phải một quý. Zustand v5.0.14 đã ổn định, tiết kiệm bundle là thật, và API sẽ không làm khó ai đã dùng React hooks hơn một tháng. Đừng migrate vì chạy theo xu hướng npm — hãy migrate vì boilerplate đang thực sự ngốn thời gian của bạn.
Nếu bạn đang đánh giá lựa chọn cho project mới thay vì lên kế hoạch migration, Zustand vs Redux Toolkit bao gồm so sánh kèm benchmark. Nếu bạn đã migration xong và muốn so sánh Zustand với các thư viện state nhẹ hơn, Zustand vs Jotai phân tích sự khác biệt về API và hiệu năng.