# Bài 14 — Lưu giỏ hàng khi F5 với redux-persist
⬅️ Bài trước | Mục lục | Bài tiếp theo ➡️
🎯 Mục tiêu
- Hiểu vấn đề: state Redux mất khi tải lại trang
- Cấu hình
redux-persistđể lưu vàolocalStorage - Dùng
PersistGateđể chờ dữ liệu được khôi phục - Xử lý cảnh báo serializable và lỗi hydration
📚 1. Vấn đề
Thử ngay: thêm vài sản phẩm vào giỏ, rồi bấm F5.
→ Giỏ hàng về 0.
Vì Redux store nằm trong bộ nhớ RAM của trang web. Tải lại trang = tạo lại JavaScript context từ đầu = mọi thứ trong RAM biến mất.
Với một website bán hàng, đây là trải nghiệm tệ: khách chọn 10 món, lỡ bấm F5, mất hết.
📚 2. Ba nơi lưu dữ liệu ở trình duyệt
| Dung lượng | Tồn tại đến khi | Gửi lên server? | |
|---|---|---|---|
| localStorage | ~5–10 MB | Người dùng tự xóa | ❌ Không |
| sessionStorage | ~5 MB | Đóng tab | ❌ Không |
| Cookie | ~4 KB | Hết hạn đã đặt | ✅ Mỗi request |
Giỏ hàng phù hợp với localStorage: dung lượng đủ lớn, tồn tại lâu, và không cần gửi lên server ở mỗi request.
Dùng localStorage thủ công thế nào?
localStorage.setItem("cart", JSON.stringify(cart)); // ghi
const cart = JSON.parse(localStorage.getItem("cart")); // đọc
localStorage.removeItem("cart"); // xóa
Nếu tự làm, bạn phải:
- Ghi lại mỗi khi state đổi (
useEffectvới dependency làcart) - Đọc lại khi khởi động ứng dụng
- Xử lý trường hợp
localStoragerỗng hoặc dữ liệu hỏng - Xử lý việc server không có
localStorage(SSR)
redux-persist làm tất cả những việc đó.
💻 3. Cấu hình store với redux-persist
Cập nhật frontend/redux/store.js:
import { combineReducers, configureStore } from "@reduxjs/toolkit";
import storage from "redux-persist/lib/storage";
import { persistReducer, persistStore } from "redux-persist";
import cartSlicer from "./slices/cartSlicer";
import sortSlide from "./slices/sortSlide";
import filterSlicer from "./slices/filterSlicer";
const persistConfig = {
key: "root",
storage,
};
const rootReducer = combineReducers({
cart: cartSlicer,
sort: sortSlide,
filter: filterSlicer,
});
const persistedReducer = persistReducer(persistConfig, rootReducer);
export const store = configureStore({
reducer: persistedReducer,
});
export const persistor = persistStore(store);
🔍 Giải thích
storage từ redux-persist/lib/storage chính là localStorage.
Có thể đổi thành:
redux-persist/lib/storage/session→sessionStorageredux-persist-indexeddb-storage→ IndexedDB (dung lượng lớn hơn)
persistConfig.key: "root" — tên khóa trong localStorage.
Dữ liệu sẽ nằm ở persist:root.
persistReducer(config, rootReducer) — bọc reducer gốc bằng một lớp
tự động ghi state xuống storage sau mỗi action.
persistStore(store) — tạo persistor, đối tượng quản lý việc
khôi phục state khi ứng dụng khởi động.
💻 4. PersistGate
Cập nhật frontend/redux/provider.js:
"use client";
import { Provider } from "react-redux";
import { persistor, store } from "./store";
import { PersistGate } from "redux-persist/integration/react";
export default function Providers({ children }) {
return (
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
{children}
</PersistGate>
</Provider>
);
}
PersistGate làm gì?
Việc đọc từ localStorage là bất đồng bộ. Trong khoảnh khắc đó, store vẫn
đang giữ initialState (giỏ hàng rỗng).
Nếu render ngay, người dùng sẽ thấy badge nhấp nháy: 0 → 5.
PersistGate hoãn việc render children cho đến khi state được khôi phục xong.
loading={null} — không hiện gì khi chờ
Có thể thay bằng spinner:
<PersistGate
loading={
<div className="d-flex justify-content-center align-items-center vh-100">
<div className="spinner-border text-dark" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
}
persistor={persistor}
>
{children}
</PersistGate>
💡 Với localStorage, việc khôi phục chỉ mất vài mili giây —
loading={null}là đủ. Spinner sẽ nhấp nháy khó chịu hơn là không hiện gì.
⚠️ 5. Cảnh báo serializable
Chạy ứng dụng, bạn sẽ thấy trong Console:
A non-serializable value was detected in an action, in the path: `register`.
Value: PersistPartial ...
Take a look at the logic that dispatched this action: {type: 'persist/PERSIST', ...}
Vì sao?
configureStore mặc định bật middleware serializableCheck — nó kiểm tra
mọi action và state phải là dữ liệu thuần (serialize được thành JSON).
redux-persist dispatch các action nội bộ (persist/PERSIST, persist/REHYDRATE...)
có chứa hàm callback — không serialize được.
Đây là cảnh báo vô hại, nhưng làm rác console và che mất lỗi thật.
Cách sửa
import {
persistStore, persistReducer,
FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER,
} from "redux-persist";
export const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}),
});
⚠️ Source gốc không có phần này → console luôn đầy cảnh báo.
💻 6. Store hoàn chỉnh
// frontend/redux/store.js — PHIÊN BẢN HOÀN CHỈNH
import { combineReducers, configureStore } from "@reduxjs/toolkit";
import storage from "redux-persist/lib/storage";
import {
persistReducer,
persistStore,
FLUSH,
REHYDRATE,
PAUSE,
PERSIST,
PURGE,
REGISTER,
} from "redux-persist";
import cartSlicer from "./slices/cartSlicer";
import sortSlide from "./slices/sortSlide";
import filterSlicer from "./slices/filterSlicer";
const persistConfig = {
key: "root",
storage,
version: 1,
// Chỉ lưu giỏ hàng. Sort/filter là trạng thái tạm, không cần nhớ qua phiên
whitelist: ["cart"],
};
const rootReducer = combineReducers({
cart: cartSlicer,
sort: sortSlide,
filter: filterSlicer,
});
const persistedReducer = persistReducer(persistConfig, rootReducer);
export const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}),
});
export const persistor = persistStore(store);
whitelist / blacklist
whitelist: ["cart"] // CHỈ lưu cart
blacklist: ["sort", "filter"] // Lưu tất cả TRỪ sort và filter
Chỉ nên lưu thứ thực sự cần nhớ. Với dự án này:
| State | Lưu? | Lý do |
|---|---|---|
cart |
✅ | Khách không muốn mất giỏ hàng |
sort |
❌ | Mở lại web mà vẫn đang sắp xếp giảm dần thì lạ |
filter |
❌ | Tương tự — người dùng quên là mình đã lọc, tưởng web lỗi |
Source gốc lưu cả ba → mở lại web, sản phẩm bị lọc mất mà không hiểu vì sao.
version — dùng khi cấu trúc state thay đổi
Nếu sau này bạn đổi cấu trúc cart (VD: từ mảng sang object), dữ liệu cũ trong
localStorage sẽ không tương thích. Tăng version và thêm migrate:
import { createMigrate } from "redux-persist";
const migrations = {
2: (state) => ({ ...state, cart: [] }), // v1 → v2: xóa giỏ cũ
};
const persistConfig = {
key: "root",
storage,
version: 2,
migrate: createMigrate(migrations),
};
✅ 7. Kiểm thử
- Thêm 3 sản phẩm vào giỏ → badge hiện
3 - Bấm F5 → badge vẫn
3✅ - Đóng hẳn tab, mở lại
localhost:3000→ badge vẫn3✅ - Mở DevTools → tab Application → Local Storage →
http://localhost:3000→ thấy khóapersist:rootvới giá trị dạng:{"cart":"[{\"_id\":\"667...\",\"name\":\"Gucci...\",\"quantity\":2,\"size\":\"s\"}]","_persist":"{\"version\":1,\"rehydrated\":true}"} - Console không còn cảnh báo
non-serializable value
Xóa dữ liệu đã lưu (khi cần test lại từ đầu)
Cách 1 — DevTools: Application → Local Storage → chuột phải persist:root → Delete
Cách 2 — Console:
localStorage.removeItem("persist:root");
location.reload();
Cách 3 — Trong code:
import { persistor } from "@/redux/store";
persistor.purge();
🆘 8. Lỗi Hydration
Đây là lỗi hay gặp nhất khi kết hợp Next.js SSR với redux-persist.
Thông báo
Error: Hydration failed because the initial UI does not match what was rendered on the server.
hoặc
Warning: Text content did not match. Server: "0" Client: "3"
Nguyên nhân
1. Server render HTML: badge = 0 (server không có localStorage)
2. HTML gửi về trình duyệt
3. React "hydrate" — gắn JS vào HTML
4. redux-persist đọc localStorage: cart có 3 món
5. React so sánh: server nói "0", client nói "3" → KHÔNG KHỚP → lỗi
Cách sửa
Cách 1 — PersistGate (đã dùng ở trên)
PersistGate không render children cho đến khi rehydrate xong. Trong lần render
đầu ở client, nó hiện loading (là null) — khớp với server.
Đây thường là đủ.
Cách 2 — Chỉ render sau khi mount (cho component cụ thể)
"use client"
import { useState, useEffect } from "react";
import { useSelector } from "react-redux";
export default function CartBadge() {
const [mounted, setMounted] = useState(false);
const cart = useSelector((state) => state.cart);
useEffect(() => setMounted(true), []);
// Lần render đầu (cả server lẫn client) đều hiện 0 → khớp nhau
if (!mounted) return <span className="badge bg-danger">0</span>;
const total = cart.reduce((sum, item) => sum + item.quantity, 0);
return <span className="badge bg-danger">{total}</span>;
}
Cách 3 — dynamic với ssr: false
import dynamic from "next/dynamic";
const Navbar = dynamic(() => import("./component/navbar"), { ssr: false });
Component này sẽ chỉ render ở client, bỏ qua SSR hoàn toàn. Đơn giản nhưng mất lợi ích SEO — chỉ dùng cho component không quan trọng với SEO (navbar giỏ hàng thì được).
📚 9. Giới hạn của localStorage
| Vấn đề | Ý nghĩa thực tế |
|---|---|
| Chỉ trên 1 trình duyệt, 1 máy | Thêm giỏ ở laptop, mở điện thoại không thấy |
| Ai cũng sửa được qua DevTools | Không được tin dữ liệu này ở backend |
| Dung lượng ~5–10 MB | Giỏ hàng vài trăm món vẫn ổn |
| Chế độ ẩn danh: xóa khi đóng | Bình thường |
Về bảo mật
Người dùng có thể mở Console và gõ:
const data = JSON.parse(localStorage.getItem("persist:root"));
const cart = JSON.parse(data.cart);
cart[0].price = 1;
data.cart = JSON.stringify(cart);
localStorage.setItem("persist:root", JSON.stringify(data));
location.reload();
→ Giỏ hàng hiển thị sản phẩm giá 1đ.
Đây chính xác là lý do bài 08 dạy backend phải tra giá từ database
và tự tính lại total_money. Frontend chỉ để hiển thị; backend mới là
nơi quyết định số tiền thật.
Nâng cấp: lưu giỏ hàng theo tài khoản
Sau bài 19 (có đăng nhập), bạn có thể lưu giỏ hàng ở server:
Chưa đăng nhập → localStorage (như hiện tại)
Đã đăng nhập → gọi API POST /cart, lưu vào MongoDB theo userId
Khi đăng nhập → gộp giỏ localStorage vào giỏ server
📝 Bài tập
-
Thêm nút "Xóa toàn bộ giỏ hàng" ở trang
/cart, cóconfirm()xác nhận, dispatchremoveCart(). -
Đổi sang
sessionStoragerồi kiểm tra: F5 vẫn giữ giỏ hàng, nhưng đóng tab mở lại thì mất. Giải thích khác biệt. -
Thêm
expiresAtcho giỏ hàng: lưu thêm timestamp, khi khởi động nếu giỏ hàng cũ hơn 7 ngày thì tự xóa. Gợi ý: dùngtransformcủa redux-persist, hoặc kiểm tra tronguseEffectở layout. -
Tự tạo lỗi hydration: bỏ
PersistGatera khỏiprovider.js, thêm sản phẩm vào giỏ rồi F5. Đọc kỹ thông báo lỗi trong console. Sau đó thêm lại. -
Thử "hack" giỏ hàng bằng đoạn code ở mục 9, rồi đặt hàng. Kiểm tra trong MongoDB xem
total_moneyđược lưu là bao nhiêu.- Với backend bản gốc (bài 08 mục 3): số bị hack được lưu 🚨
- Với backend bản cải tiến (bài 08 mục 5): số đúng được tính lại ✅
⬅️ Bài trước | Mục lục | Bài tiếp theo: Sắp xếp & lọc sản phẩm ➡️
All rights reserved