0

# Bài 13 — Redux Toolkit & giỏ hàng

⬅️ Bài trước | Mục lục | Bài tiếp theo ➡️


🎯 Mục tiêu

  • Hiểu vì sao cần Redux khi đã có useState
  • Nắm 4 khái niệm: store, slice, action, reducer
  • Viết cartSlicer với các thao tác thêm/xóa/sửa/xóa hết
  • Nối Redux vào ứng dụng bằng Provider
  • Navbar hiện số lượng giỏ hàng, nút "Thêm vào giỏ" hoạt động

📚 1. Vì sao cần Redux?

Vấn đề: state cần chia sẻ giữa các component xa nhau

app/(user)/layout.js
├── Navbar               ← cần ĐỌC giỏ hàng (hiện số lượng)
└── page.js
    └── Productlist
        └── Product
            └── AddCardbutton   ← cần GHI vào giỏ hàng

AddCardbutton và Navbar cách nhau 4 tầng. Với useState thuần, bạn phải:

  1. Đặt state ở layout.js (tổ tiên chung gần nhất)
  2. Truyền cart và setCart xuống qua từng tầng props

Đây gọi là prop drilling — mỗi component trung gian phải nhận và chuyển tiếp props mà chính nó không dùng. Code rối, sửa một chỗ phải sửa nhiều file.

Giải pháp: kho dữ liệu chung

Redux tạo một store — kho dữ liệu toàn cục. Component nào cần thì lấy trực tiếp, không cần đi qua các tầng trung gian.

              ┌──────────────┐
              │    STORE     │
              │  cart: [...] │
              │  sort: null  │
              │  filter: {}  │
              └──────┬───────┘
         ┌───────────┼───────────┐
         │           │           │
    ┌────▼───┐  ┌────▼────┐ ┌───▼────────┐
    │ Navbar │  │ AddCard │ │ Productlist│
    │ (đọc)  │  │ (ghi)   │ │ (đọc)      │
    └────────┘  └─────────┘ └────────────┘

Khi nào không cần Redux?

Redux không phải giải pháp cho mọi thứ. Trong dự án này:

State Nơi lưu Lý do
Giỏ hàng ✅ Redux Nhiều component xa nhau cùng dùng
Kiểu sắp xếp ✅ Redux Sidebar ghi, Productlist đọc
Khoảng giá lọc ✅ Redux Như trên
Số lượng đang chọn ở trang chi tiết ❌ useState Chỉ component đó dùng
Danh sách sản phẩm từ API ❌ SWR SWR đã lo cache rồi

💡 Quy tắc: dữ liệu từ server → dùng SWR. State UI cục bộ → useState. Chỉ đưa vào Redux thứ thực sự dùng chung ở nhiều nơi.


📚 2. Bốn khái niệm cốt lõi

Store — kho chứa

Một object JavaScript duy nhất chứa toàn bộ state của ứng dụng.

{
  cart:   [{ _id: "...", name: "Gucci", quantity: 2, size: "s" }],
  sort:   "ASC",
  filter: { min: 0, max: 99999999 }
}

Action — mô tả "chuyện gì xảy ra"

Một object mô tả sự kiện:

{ type: "cart/addItem", payload: { product, quantity: 1, size: "s" } }

Reducer — hàm quyết định state mới

(stateHienTai, action) => stateMoi

Reducer phải thuần: không gọi API, không random, không sửa state cũ — chỉ tính ra state mới từ input.

Slice — gói gọn cả 3 thứ trên

Redux Toolkit gộp state + action + reducer của một "mảng nghiệp vụ" vào 1 file:

const cartSlice = createSlice({
  name: "cart",           // tiền tố cho action type
  initialState: [],       // giá trị ban đầu
  reducers: {             // các hàm xử lý
    addItem: (state, action) => { /* ... */ },
    removeItem: (state, action) => { /* ... */ },
  },
});

createSlice tự động sinh ra action creators — bạn không cần viết tay.


💻 3. Viết cartSlicer

Tạo frontend/redux/slices/cartSlicer.js:

import { createSlice } from "@reduxjs/toolkit";

export const cartSlice = createSlice({
  name: "cart",
  initialState: [],
  reducers: {
    // Thêm sản phẩm vào giỏ
    addItem: (state, action) => {
      const { product, quantity, size } = action.payload;
      const index = state.findIndex(
        (item) => item._id === product._id && item.size === size
      );

      if (index === -1) {
        // Chưa có → thêm mới
        state.push({ ...product, quantity: Number(quantity), size });
      } else {
        // Đã có → cộng dồn số lượng
        state[index].quantity = Number(state[index].quantity) + Number(quantity);
      }
    },

    // Xóa 1 sản phẩm khỏi giỏ
    removeItem: (state, action) => {
      const { product, size } = action.payload;
      const index = state.findIndex(
        (item) => item._id === product._id && item.size === size
      );
      if (index !== -1) {
        state.splice(index, 1);
      }
    },

    // Cập nhật số lượng
    updateItem: (state, action) => {
      const { product, quantity, size } = action.payload;
      const index = state.findIndex(
        (item) => item._id === product._id && item.size === size
      );
      if (index !== -1) {
        state[index].quantity = Math.max(1, Number(quantity) || 1);
      }
    },

    // Xóa toàn bộ giỏ hàng
    removeCart: () => [],
  },
});

export const { addItem, removeItem, updateItem, removeCart } = cartSlice.actions;
export default cartSlice.reducer;

🔍 4. Giải thích chi tiết

name: "cart" sinh ra action type

name: "cart" + reducer "addItem"  →  action type "cart/addItem"

Tiền tố này giúp phân biệt action của các slice khác nhau khi debug.

⚠️ Lỗi trong source gốc: file sortSlide.js cũng đặt name: 'cart':

export const sortSlide = createSlice({
  name: 'cart',          // ← trùng với cartSlicer!
  initialState: null,
  ...
})

Nó vẫn chạy vì mỗi slice có reducer riêng, nhưng khi mở Redux DevTools bạn sẽ thấy cart/sortASC — rất khó hiểu. Đúng ra phải là name: 'sort'.

state.push(...) — vì sao được phép "sửa" state?

Bạn được dạy là không bao giờ sửa state trực tiếp. Nhưng ở đây lại push, splice?

Redux Toolkit có thư viện Immer bên trong. Nó bọc state bằng một Proxy:

  • Bạn "viết như đang sửa trực tiếp" — dễ đọc
  • Immer ghi lại mọi thay đổi và tạo ra object mới ở phía sau
  • State gốc không bị đụng tới
// Với Immer (Redux Toolkit) — dễ đọc
addItem: (state, action) => {
  state.push(newItem);
}

// Không có Immer (Redux cũ) — dài dòng
addItem: (state, action) => {
  return [...state, newItem];
}

⚠️ Quy tắc Immer: hoặc sửa, hoặc return — không làm cả hai

// ✅ Sửa, không return
addItem: (state, action) => {
  state.push(item);
}

// ✅ Return giá trị mới, không sửa
removeCart: () => [],

// ❌ SAI — vừa sửa vừa return
addItem: (state, action) => {
  state.push(item);
  return state;        // Immer sẽ bối rối
}

Source gốc mắc lỗi này:

addItem: (state, action) => {
  const cart = state;
  const index = cart.findIndex(...);
  if (index == -1) {
    cart.push({...product, quantity, size});
    state = cart;             // ← gán vô nghĩa, `state` là tham số cục bộ
  } else {
    cart[index].quantity = ...;
  }
  state = cart;               // ← lại gán vô nghĩa
  return state;               // ← vừa sửa vừa return
}

Ba dòng state = cart hoàn toàn không có tác dụng — gán lại tham số của hàm không ảnh hưởng gì tới bên ngoài. Code vẫn chạy nhờ cart.push() sửa trực tiếp, nhưng rất dễ gây hiểu nhầm.

state.splice(index, 1) — bảo vệ khi không tìm thấy

Source gốc:

removeItem: (state, action) => {
  const index = cart.findIndex(...);
  cart.splice(index, 1);      // ⚠️ nếu index = -1?
  return state;
}

Khi findIndex không tìm thấy, nó trả về -1. Và splice(-1, 1) xóa phần tử cuối cùng!

→ Xóa nhầm sản phẩm khác. Bản trên đã thêm if (index !== -1).

removeCart: () => []

Source gốc viết: removeCart: (state) => (state = [])

Phép gán state = [] không có tác dụng, nhưng arrow function rút gọn => (biểu thức) tự động return giá trị của biểu thức — mà giá trị của state = [] chính là [].

Nên nó tình cờ hoạt động đúng. Bản trên viết rõ ràng hơn: () => [].

decreaseItem: (state, action) => {} — hàm rỗng

Source gốc khai báo action decreaseItem nhưng thân hàm rỗng. Gọi nó sẽ không làm gì cả. Đây là code viết dở dang. Bản trên đã bỏ.

So sánh bằng _id và size

(item) => item._id === product._id && item.size === size

Cùng một chai nước hoa nhưng size S và size M là 2 dòng riêng trong giỏ. Nên phải so sánh cả hai để biết nên cộng dồn hay thêm mới.

Number(quantity)

Input HTML luôn trả về chuỗi. Nếu không ép kiểu:

"1" + "2"       // "12"  ← nối chuỗi!
Number("1") + Number("2")   // 3  ✅

💻 5. Hai slice còn lại

frontend/redux/slices/sortSlide.js

import { createSlice } from "@reduxjs/toolkit";

export const sortSlide = createSlice({
  name: "sort",                // đã sửa từ "cart" thành "sort"
  initialState: null,          // null = mặc định, "ASC" = tăng, "DESC" = giảm
  reducers: {
    sortASC: () => "ASC",
    sortDESC: () => "DESC",
    sortDefault: () => null,
  },
});

export const { sortASC, sortDESC, sortDefault } = sortSlide.actions;
export default sortSlide.reducer;

frontend/redux/slices/filterSlicer.js

import { createSlice } from "@reduxjs/toolkit";

export const filterSlices = createSlice({
  name: "filter",
  initialState: { min: 0, max: 99999999 },
  reducers: {
    setMin: (state, action) => {
      state.min = Number(action.payload) || 0;
    },
    setMax: (state, action) => {
      state.max = Number(action.payload) || 99999999;
    },
    resetFilter: () => ({ min: 0, max: 99999999 }),
  },
});

export const { setMin, setMax, resetFilter } = filterSlices.actions;
export default filterSlices.reducer;

⚠️ Source gốc không ép kiểu: state.min = actions.payload. Giá trị đến từ <input type="number"> là chuỗi, nên phép so sánh min <= item.price biến thành "10000" <= 5050000 — JavaScript ép chuỗi thành số nên tình cờ vẫn đúng. Nhưng nếu ô input rỗng, payload là "" và "" <= 5050000 cũng là true... còn item.price <= "" là false → mất hết sản phẩm.

Ép kiểu tường minh như bản trên tránh được cả lớp lỗi này.


💻 6. Tạo store

Tạo frontend/redux/store.js (bản chưa có persist — bài 14 sẽ thêm):

import { combineReducers, configureStore } from "@reduxjs/toolkit";
import cartSlicer from "./slices/cartSlicer";
import sortSlide from "./slices/sortSlide";
import filterSlicer from "./slices/filterSlicer";

const rootReducer = combineReducers({
  cart: cartSlicer,
  sort: sortSlide,
  filter: filterSlicer,
});

export const store = configureStore({
  reducer: rootReducer,
});

combineReducers — ghép nhiều slice

combineReducers({
  cart: cartSlicer,       // → state.cart
  sort: sortSlide,        // → state.sort
  filter: filterSlicer,   // → state.filter
})

Key bên trái quyết định tên trong store. Đó là lý do bạn viết state.cart, state.sort.


💻 7. Provider — nối Redux vào React

Tạo frontend/redux/provider.js:

"use client";
import { Provider } from "react-redux";
import { store } from "./store";

export default function Providers({ children }) {
  return <Provider store={store}>{children}</Provider>;
}

Gắn vào frontend/app/(user)/layout.js:

import { Inter } from "next/font/google";
import "./globals.css";
import "@/public/css/bootstrap.min.css";
import Navbar from "./component/navbar.js";
import Providers from "@/redux/provider";

const inter = Inter({ subsets: ["latin"] });

export const metadata = {
  title: "Lith Perfume",
  description: "Website bán nước hoa chính hãng",
};

export default function RootLayout({ children }) {
  return (
    <html lang="vi">
      <body className={inter.className}>
        <Providers>
          <Navbar />
          {children}
        </Providers>
        <script src="https://kit.fontawesome.com/7b8b40ab0a.js" crossOrigin="anonymous"></script>
        <script src="/js/bootstrap.bundle.min.js"></script>
      </body>
    </html>
  );
}

⚠️ Vì sao cần file provider.js riêng?

layout.js là Server Component. Nhưng <Provider> của react-redux cần chạy ở client (nó dùng React Context).

Nếu thêm "use client" vào layout.js, bạn sẽ mất export const metadata (chỉ hoạt động ở server component).

Giải pháp: tách phần cần client ra file riêng có "use client". Đây là pattern chuẩn của Next.js App Router — bạn sẽ gặp lại nhiều lần.

⚠️ Phải bọc cả Navbar

<Providers>
  <Navbar />      {/* ← phải nằm TRONG Providers */}
  {children}
</Providers>

Navbar dùng useSelector. Nếu để ngoài <Providers>, bạn sẽ gặp:

Error: could not find react-redux context value;
please ensure the component is wrapped in a <Provider>

📚 8. Đọc và ghi store trong component

useSelector — đọc

"use client"
import { useSelector } from "react-redux";

const cart = useSelector((state) => state.cart);
const sortType = useSelector((state) => state.sort);
const { min, max } = useSelector((state) => state.filter);

Component sẽ tự động render lại khi phần state nó chọn thay đổi.

💡 Mẹo hiệu năng: chọn càng nhỏ càng tốt.

const cart = useSelector((s) => s.cart);              // render lại khi cart đổi
const soLuong = useSelector((s) => s.cart.length);    // chỉ render khi độ dài đổi

useDispatch — ghi

"use client"
import { useDispatch } from "react-redux";
import { addItem } from "@/redux/slices/cartSlicer";

const dispatch = useDispatch();

<button onClick={() => dispatch(addItem({ product, quantity, size }))}>
  Thêm vào giỏ
</button>

addItem({...}) không thực thi gì — nó chỉ tạo ra action object:

{ type: "cart/addItem", payload: { product, quantity, size } }

dispatch(...) mới là thứ gửi action đó vào store để reducer xử lý.


📚 9. Redux DevTools — công cụ debug tuyệt vời

Cài extension Redux DevTools cho Chrome/Firefox.

configureStore đã tự bật nó — không cần cấu hình gì thêm.

Mở DevTools (F12) → tab Redux, bạn sẽ thấy:

  • Danh sách mọi action đã dispatch theo thứ tự thời gian
  • State trước và sau mỗi action
  • Diff (phần thay đổi) được tô màu
  • Time travel — bấm vào action cũ để tua ngược trạng thái ứng dụng

Đây là công cụ debug mạnh nhất bạn có với Redux. Hãy tập dùng nó.


✅ 10. Kiểm thử

Chạy cả 2 server, mở http://localhost:3000:

  1. Badge giỏ hàng hiện 0 ở navbar
  2. Bấm "Đặt Mua" ở một sản phẩm → badge chuyển thành 1
  3. Bấm lại cùng sản phẩm → badge thành 2 (cộng dồn, không tạo dòng mới)
  4. Vào trang chi tiết, chọn size M, số lượng 3, bấm "Thêm Vào Giỏ Hàng" → badge thành 5 (2 + 3)
  5. Mở Redux DevTools → thấy các action cart/addItem với payload đầy đủ

Kiểm tra trong Redux DevTools

Sau bước 4, state phải là:

{
  cart: [
    { _id: "6675905e...", name: "Gucci Flora...", quantity: 2, size: "s" },
    { _id: "6675905e...", name: "Gucci Flora...", quantity: 3, size: "m" }
  ],
  sort: null,
  filter: { min: 0, max: 99999999 }
}

Hai dòng riêng biệt vì khác size — đúng như thiết kế.


🆘 Xử lý sự cố

<details> <summary>could not find react-redux context value</summary>

Component đang dùng useSelector/useDispatch nhưng không nằm trong <Provider>.

Kiểm tra layout.js: <Navbar /> và {children} đều phải nằm bên trong <Providers>.

</details> <details> <summary>Cannot read properties of undefined (reading 'reduce')</summary>

state.cart là undefined. Nguyên nhân: key trong combineReducers không khớp với tên bạn dùng trong useSelector.

combineReducers({ cart: cartSlicer })   // → phải dùng state.cart
useSelector((state) => state.carts)     // ❌ sai tên
</details> <details> <summary>Bấm nút nhưng badge không đổi</summary>
  1. Mở Redux DevTools — action có được dispatch không?
  2. Nếu có action nhưng state không đổi → reducer viết sai (thường là quên state.push hoặc so sánh sai điều kiện)
  3. Nếu không có action → onClick chưa gắn đúng, hoặc quên "use client"
</details> <details> <summary>A non-serializable value was detected in the state</summary>

Bạn đưa vào store thứ không serialize được (Date, Function, Map...). Redux yêu cầu state chỉ chứa dữ liệu thuần (số, chuỗi, boolean, mảng, object).

Bài 14 sẽ gặp cảnh báo này với redux-persist và có cách xử lý.

</details>

📝 Bài tập

  1. Thêm action decreaseItem — giảm số lượng đi 1, nếu về 0 thì xóa khỏi giỏ.

  2. Thêm selector tính tổng tiền:

    // redux/slices/cartSlicer.js
    export const selectTotal = (state) =>
      state.cart.reduce((sum, item) => sum + item.price * item.quantity, 0);
    

    Dùng: const total = useSelector(selectTotal);

  3. Giới hạn số lượng tối đa 10 mỗi sản phẩm trong addItem và updateItem.

  4. Tạo slice wishlist (danh sách yêu thích) với 3 action: addToWishlist, removeFromWishlist, toggleWishlist. Thêm nút trái tim vào Product.

  5. Thí nghiệm với Immer: thêm console.log(state) vào đầu reducer addItem. Quan sát nó in ra Proxy chứ không phải mảng thường. Thử console.log(current(state)) (import current từ @reduxjs/toolkit) để thấy giá trị thật.

  6. Tái hiện bug splice(-1, 1): sửa removeItem bỏ đi if (index !== -1), rồi thử xóa một sản phẩm với size không tồn tại trong giỏ. Quan sát sản phẩm cuối cùng bị xóa nhầm.


⬅️ Bài trước | Mục lục | Bài tiếp theo: Redux Persist ➡️


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í