# 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
cartSlicervớ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:
- Đặt state ở
layout.js(tổ tiên chung gần nhất) - Truyền
cartvàsetCartxuố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.jscũng đặtname: '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ánhmin <= item.pricebiế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,payloadlà""và"" <= 5050000cũng làtrue... cònitem.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:
- Badge giỏ hàng hiện
0ở navbar - Bấm "Đặt Mua" ở một sản phẩm → badge chuyển thành
1 - 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) - Vào trang chi tiết, chọn size
M, số lượng3, bấm "Thêm Vào Giỏ Hàng" → badge thành5(2 + 3) - Mở Redux DevTools → thấy các action
cart/addItemvớ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>.
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>
- Mở Redux DevTools — action có được dispatch không?
- Nếu có action nhưng state không đổi → reducer viết sai
(thường là quên
state.pushhoặc so sánh sai điều kiện) - Nếu không có action →
onClickchưa gắn đúng, hoặc quên"use client"
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
-
Thêm action
decreaseItem— giảm số lượng đi 1, nếu về 0 thì xóa khỏi giỏ. -
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); -
Giới hạn số lượng tối đa 10 mỗi sản phẩm trong
addItemvàupdateItem. -
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àoProduct. -
Thí nghiệm với Immer: thêm
console.log(state)vào đầu reduceraddItem. Quan sát nó in raProxychứ không phải mảng thường. Thửconsole.log(current(state))(importcurrenttừ@reduxjs/toolkit) để thấy giá trị thật. -
Tái hiện bug
splice(-1, 1): sửaremoveItembỏ điif (index !== -1), rồi thử xóa một sản phẩm vớisizekhông tồn tại trong giỏ. Quan sát sản phẩm cuối cùng bị xóa nhầm.
All rights reserved