0

# 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ào localStorage
  • 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 (useEffect với dependency là cart)
  • Đọc lại khi khởi động ứng dụng
  • Xử lý trường hợp localStorage rỗ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 → sessionStorage
  • redux-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ử

  1. Thêm 3 sản phẩm vào giỏ → badge hiện 3
  2. Bấm F5 → badge vẫn 3 ✅
  3. Đóng hẳn tab, mở lại localhost:3000 → badge vẫn 3 ✅
  4. Mở DevTools → tab Application → Local Storage → http://localhost:3000 → thấy khóa persist:root với giá trị dạng:
    {"cart":"[{\"_id\":\"667...\",\"name\":\"Gucci...\",\"quantity\":2,\"size\":\"s\"}]","_persist":"{\"version\":1,\"rehydrated\":true}"}
    
  5. 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

  1. Thêm nút "Xóa toàn bộ giỏ hàng" ở trang /cart, có confirm() xác nhận, dispatch removeCart().

  2. Đổi sang sessionStorage rồ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.

  3. Thêm expiresAt cho 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ùng transform của redux-persist, hoặc kiểm tra trong useEffect ở layout.

  4. Tự tạo lỗi hydration: bỏ PersistGate ra khỏi provider.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.

  5. 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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí