Bài 19 — Redux Toolkit: slice, action, reducer, selector
Phần 3 · Frontend React — Thời lượng ước tính: ~75 phút ⬅️ Bài trước: 18 — Tầng gọi API với axios · Bài sau: 20 —
createAsyncThunkvàextraReducers➡️ 🏠 Mục lục
🎯 Sau bài này bạn sẽ
- Chỉ ra được prop drilling là gì và vì sao Yotea sẽ rất khổ nếu không có Redux.
- Đọc hiểu trọn vẹn một slice thật:
cartSlice.js— 49 dòng, 4 reducer, 1 selector. - Giải thích được vì sao
cart.push(...)trong reducer vẫn đúng nguyên tắc bất biến (nhờ Immer). - Dùng thành thạo
useSelectorđể đọc vàuseDispatchđể thay đổi state. - Hiểu
combineReducersghép 11 slice thành một cây state duy nhất như thế nào. - Tự viết
src/redux/toppingSlice.js, đăng ký vàorootReducervà dùng được trongToppingPage.
📋 Cần chuẩn bị
- Đã hoàn thành Bài 18: bạn đã có
yotea-fe/src/api/topping.jsvới đủgetAll / get / add / update / remove. - Đã có
ToppingPage.jsvàToppingCard.jstừ Bài 16, Bài 17. - Nắm destructuring, spread/rest, arrow function (Bài 03).
Ở bài trước bạn đã làm tầng API — nơi nói chuyện với server. Bài này ta làm tiếp tầng state — nơi cất giữ dữ liệu đã lấy về để mọi component cùng dùng chung.
1. Vấn đề Redux sinh ra để giải quyết: prop drilling
Nhìn cái badge số món trong giỏ ở góc phải header.
yotea-fe/src/pages/layouts/WebsiteLayout.js:216-221
<Link to="/cart" className="relative">
<label className="absolute w-4 h-4 bg-green-700 text-xs text-center rounded-full -right-3 -top-1">
{cart.length}
</label>
<FontAwesomeIcon icon={faShoppingCart} />
</Link>
Con số cart.length nằm trong WebsiteLayout — đỉnh cây component. Nhưng nút "Thêm vào
giỏ hàng" lại nằm trong ProductDetailPage, tận sâu bên trong <Outlet />:
WebsiteLayout ← badge "cart.length" hiển thị Ở ĐÂY
└── <Outlet />
└── ProductDetailPage ← nút "Thêm vào giỏ hàng" bấm Ở ĐÂY
└── form đá/đường
Không có Redux, bạn buộc phải nâng state lên cha chung rồi truyền props xuyên qua từng tầng:
// ❌ Cách KHÔNG dùng Redux — prop drilling, đoạn này chỉ để minh hoạ
<WebsiteLayout cart={cart} setCart={setCart}>
<Outlet context={{ cart, setCart }} />
<ProductDetailPage cart={cart} setCart={setCart} />
📖 Thuật ngữ: prop drilling — "khoan props". Dữ liệu phải xuyên qua hàng loạt component trung gian không hề cần đến nó, chỉ để tới được component ở đáy.
| Hậu quả | Cụ thể trong Yotea |
|---|---|
| Component trung gian bị "ô nhiễm" | Outlet, ProductContent, HomeProducts… phải nhận cart dù chẳng dùng |
| Đổi cấu trúc dữ liệu là sửa cả chục file | Thêm trường vào cart item → sửa mọi chữ ký props |
| Không chia sẻ được giữa các nhánh | CartPage và header là hai nhánh khác nhau của cây |
| Re-render thừa | Cha đổi state → mọi con re-render |
Redux kéo dữ liệu ra khỏi cây component, đặt vào một cái kho đứng bên ngoài. Component nào cần thì tự với tay lấy:
┌───────────────────────┐
│ REDUX STORE │
│ { cart: {cart: []}, │
│ auth: {...}, ... } │
└───────────────────────┘
▲ │
dispatch(addCart) useSelector(selectCart)
│ ▼
ProductDetailPage WebsiteLayout (badge)
Hai bên không hề biết nhau tồn tại.
2. Ví von: Redux là một cái kho hàng có nội quy
| Khái niệm Redux | Trong nhà kho | Trong code |
|---|---|---|
| store | Cái kho — nơi duy nhất chứa hàng | yotea-fe/src/redux/store.js |
| state | Hàng đang nằm trong kho | { cart: [...], auth: {...} } |
| reducer | Thủ kho — người DUY NHẤT được động vào hàng | addCart, logout… |
| action | Phiếu yêu cầu: "làm gì" + "với cái gì" | { type: "cart/addCart", payload: {...} } |
| dispatch | Hành động nộp phiếu cho thủ kho | dispatch(addCart(cartData)) |
| selector | Phiếu tra cứu — chỉ xem, không sửa | selectCart = (state) => state.cart.cart |
Nội quy quan trọng nhất: bạn không được tự tay vào kho bốc hàng. Muốn gì cũng phải viết phiếu rồi đưa thủ kho. Nhờ vậy mọi thay đổi đều có dấu vết.
2.1. Ba nguyên tắc bất di bất dịch
1. Một nguồn sự thật duy nhất. Toàn bộ state nằm trong một object, trong một store. Không có chuyện giỏ hàng lưu ở 3 nơi rồi lệch nhau.
2. State chỉ đọc. Cách duy nhất để thay đổi là dispatch một action.
state.cart.cart.push(sanPhamMoi); // ❌ SAI
dispatch(addCart(sanPhamMoi)); // ✅ ĐÚNG
3. Thay đổi chỉ qua hàm thuần. Reducer nhận (state cũ, action) → trả về state mới.
| Reducer ĐƯỢC phép | Reducer KHÔNG được phép |
|---|---|
Tính toán từ state và action |
Gọi API, ghi localStorage |
| Trả về state mới | Đọc Date.now(), Math.random() |
Cùng một (state, action) thì luôn ra cùng kết quả — đó là lý do Redux DevTools "tua đi
tua lại" được lịch sử state.
💡 Vì sao là "Redux Toolkit" chứ không phải Redux thuần? Redux thuần bắt bạn viết tay hằng số action type, action creator,
switch...case, tự copy object bằng spread — mỗi tính năng nhỏ tốn 3 file. RTK gói tất cả vào một hàm:createSlice.
3. Soi code thật: cartSlice.js
Đây là slice duy nhất trong 11 slice của Yotea không có createAsyncThunk (giỏ hàng hoàn
toàn ở client). Vì thế nó là bài mẫu hoàn hảo để học createSlice.
yotea-fe/src/redux/cartSlice.js:1-49
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
cart: [],
};
const cartSlice = createSlice({
name: "cart",
initialState,
reducers: {
addCart({ cart }, { payload: newProduct }) {
const exitsProduct = cart.find(
(item) =>
item.productId === newProduct.productId &&
item.ice === newProduct.ice &&
item.sugar === newProduct.sugar
);
if (!exitsProduct) {
cart.push(newProduct);
} else {
exitsProduct.quantity += +newProduct.quantity;
}
},
removeItemCart(state, { payload }) {
state.cart = state.cart.filter((item) => item.id !== payload);
},
updateQuantity(state, { payload: listQuantity }) {
listQuantity.forEach((cartItem) => {
if (!cartItem.quantity) {
state.cart = state.cart.filter((item) => item.id !== cartItem.id);
} else {
const currentProduct = state.cart.find(
(item) => item.id === cartItem.id
);
currentProduct.quantity = cartItem.quantity;
}
});
},
finishOrder(state) {
state.cart = [];
},
},
});
export const selectCart = (state) => state.cart.cart;
export const { addCart, removeItemCart, updateQuantity, finishOrder } =
cartSlice.actions;
export default cartSlice.reducer;
Đọc từng dòng:
| Dòng | Code | Ý nghĩa |
|---|---|---|
| 1 | import { createSlice } |
Hàm "3 trong 1": sinh reducer + action type + action creator |
| 3-5 | initialState = { cart: [] } |
Hình dạng ban đầu của state khi app khởi động |
| 8 | name: "cart" |
Tiền tố cho mọi action type của slice này |
| 9 | initialState, |
Object shorthand — viết đủ là initialState: initialState |
| 10 | reducers: { ... } |
Nơi khai báo reducer đồng bộ (chạy xong ngay lập tức) |
| 11-24 | addCart(...) |
Thêm sản phẩm vào giỏ, có gộp trùng |
| 25-27 | removeItemCart(...) |
Xoá một dòng khỏi giỏ |
| 28-39 | updateQuantity(...) |
Cập nhật số lượng hàng loạt |
| 40-42 | finishOrder(state) |
Dọn sạch giỏ sau khi đặt hàng xong |
| 46 | export const selectCart |
Selector — hàm đọc dữ liệu ra khỏi state |
| 47-48 | = cartSlice.actions |
Lấy 4 action creator mà RTK tự sinh |
| 49 | export default cartSlice.reducer |
Reducer để lát nữa gắn vào rootReducer |
3.1. RTK tự sinh action creator như thế nào?
Bạn chỉ viết cái tên addCart. RTK tự làm phần còn lại:
Bạn viết: reducers: { addCart(state, action) {...} }
RTK sinh: • action type → "cart/addCart" (name + "/" + tên hàm)
• action creator → cartSlice.actions.addCart
Nên khi gọi dispatch(addCart(cartData)), thứ thật sự chạy trong store là một object phẳng:
{
type: "cart/addCart",
payload: { id: "a3f1-...", productId: "6650...", quantity: 2, ice: 30, sugar: 50, ... }
}
Đây chính là "tấm phiếu yêu cầu" ở mục 2: type là "làm gì", payload là "với cái gì".
💡 Mẹo đọc chữ ký reducer: tham số thứ hai luôn là cả action object. Vì gần như chỉ cần
payload, dự án destructuring luôn tại chỗ:removeItemCart(state, { payload }). Còn{ payload: listQuantity }là bócpayloadra và đổi tên cho dễ đọc.
4. Immer: vì sao cart.push(...) lại hợp lệ?
Dòng 20 viết cart.push(newProduct); — mà push là hàm sửa thẳng mảng gốc. Nguyên tắc số 2
nói "state chỉ đọc" cơ mà?
Redux Toolkit nhúng sẵn thư viện Immer. Cơ chế:
1. Bạn dispatch một action
2. Immer tạo BẢN NHÁP (draft) — một proxy bọc quanh state thật
3. Reducer của bạn "sửa" thoải mái trên BẢN NHÁP
4. Immer ghi lại từng thay đổi, TỰ TẠO state mới bất biến
5. State cũ vẫn còn nguyên vẹn
Nói cách khác: bạn viết như đang mutate, Immer dịch thành immutable.
| Bạn viết trong reducer | Immer thực sự làm |
|---|---|
cart.push(newProduct) |
return [...cartCu, newProduct] |
exitsProduct.quantity += 1 |
Nhân bản item đó với quantity mới, giữ nguyên item khác |
state.cart = [] |
Trả về object mới { cart: [] } |
Vì thế trong cartSlice cả hai kiểu viết cùng tồn tại mà vẫn đúng:
cart.push(newProduct); // kiểu "mutate" (dòng 20)
state.cart = state.cart.filter((item) => item.id !== payload); // kiểu "gán mới" (dòng 26)
Chú ý cách addCart bóc tham số ngay tại chữ ký (dòng 11): addCart({ cart }, ...). Biến
cart ở đây không phải bản sao, nó là tham chiếu tới mảng draft, nên cart.push(...)
vẫn được Immer theo dõi bình thường.
⚠️ Bẫy Immer số 1: chỉ áp dụng bên trong reducer của
createSlice. Viếtcart.push(...)trong component React thì sai hoàn toàn — React không re-render vì reference mảng không đổi.⚠️ Bẫy Immer số 2: đừng vừa mutate vừa
return. Làm cả hai sẽ ném lỗiCannot return a new state and modify the draft.
5. Mổ kỹ 4 reducer của cartSlice
5.1. addCart — gộp sản phẩm trùng (dòng 11-24)
const exitsProduct = cart.find(
(item) =>
item.productId === newProduct.productId &&
item.ice === newProduct.ice &&
item.sugar === newProduct.sugar
);
Khoá gộp là bộ ba (productId, ice, sugar), chứ không chỉ productId:
| Tình huống | Kết quả trong giỏ |
|---|---|
| Trà sữa A, đá 50%, đường 50% — thêm 2 lần | 1 dòng, quantity cộng dồn |
| Trà sữa A đá 30%, rồi trà sữa A đá 70% | 2 dòng riêng biệt |
Rất hợp nghiệp vụ: cùng món nhưng khác mức đá/đường thì pha khác nhau, phải tách dòng.
Dòng 22 exitsProduct.quantity += +newProduct.quantity; — dấu + phía trước là ép kiểu sang
số, phòng khi quantity là chuỗi "2" (nếu không, 1 + "2" ra "12").
⚠️ Chỗ này dự án làm chưa chuẩn: biến
exitsProductsai chính tả (đúng làexistsProduct), lặp 3 lần ở dòng 12, 19, 22. Ngoài raaddCartkhông kiểm traquantity <= 0và không kiểm tra tồn kho.
5.2. removeItemCart (dòng 25-27)
payload ở đây là item.id, không phải productId. Nơi gọi —
yotea-fe/src/pages/user/cart/CartPage.js:120:
dispatch(removeItemCart(cartId));
(nằm bên trong hộp xác nhận SweetAlert2, CartPage.js:109-124).
5.3. updateQuantity (dòng 28-39)
Reducer phức tạp nhất, cập nhật hàng loạt. payload là một mảng [{ id, quantity }, ...]
dựng ở yotea-fe/src/pages/user/cart/CartPage.js:26-33:
const listIdQnt = cart.map((item) => {
return {
id: item.id,
quantity: item.quantity,
};
});
setCartQnt(listIdQnt);
Luồng: gõ số vào ô input → chỉ đổi useState cục bộ cartQnt → bấm "Cập nhật giỏ hàng"
(CartPage.js:101-107) mới dispatch(updateQuantity(cartQnt)). Trong reducer, nhánh dòng 30-31
nói: "số lượng bằng 0 (hoặc rỗng) thì xoá luôn dòng đó."
⚠️ Chỗ này dự án làm chưa chuẩn — 3 lỗi trong một reducer:
- Có thể crash trắng trang. Dòng 33-36 gọi
findrồi gán thẳngcurrentProduct.quantity = .... Nếufindkhông tìm thấy (mở 2 tab, tab A đã xoá món đó) thìcurrentProductlàundefined→TypeError: Cannot set properties of undefined. Cách đúng:if (currentProduct) currentProduct.quantity = ....- Số lượng âm lọt lưới.
CartPage.js:54-55chỉ chặnisNaN. Gõ-5thìisNaN(-5) === false→ lọt vào reducer;!(-5)làfalse→ không bị xoá mà gánquantity = -5→ tổng tiền ra số âm.- Xoá ngầm không hỏi. Xoá bằng nút ✕ thì có hộp xác nhận, còn gõ
0vào ô số lượng thì món biến mất luôn — hai đường xoá, hai trải nghiệm khác nhau.
5.4. finishOrder (dòng 40-42)
Reducer đơn giản nhất: không cần payload. Chỉ được dispatch một nơi duy nhất —
yotea-fe/src/pages/user/cart/CheckoutPage.js:91, ngay sau khi tạo xong đơn hàng.
6. ⚠️ Điểm không nhất quán: item.id hay item.productId?
| Reducer | Khoá dùng để tìm | Dòng |
|---|---|---|
addCart |
productId + ice + sugar |
12-17 |
removeItemCart |
id |
26 |
updateQuantity |
id |
31, 34 |
Ba reducer cùng file dùng hai loại khoá khác nhau. Vậy id từ đâu ra?
Tìm uuid trong toàn bộ yotea-fe/src — chỉ đúng 2 dòng, cùng một file:
yotea-fe/src/pages/user/ProductDetailPage.js:20:import { v4 as uuidv4 } from "uuid";
yotea-fe/src/pages/user/ProductDetailPage.js:51: id: uuidv4(),
yotea-fe/src/pages/user/ProductDetailPage.js:39-62
const onSubmit = async ({ ice, sugar }) => {
// get data product
const { data: product } = await get(slug);
const productData = {
productSlug: product.slug,
productId: product._id,
productName: product.name,
productPrice: product.price,
productImage: product.image,
};
const cartData = {
id: uuidv4(),
...productData,
quantity,
ice: +ice,
sugar: +sugar,
};
dispatch(addCart(cartData));
toast.success(`Thêm ${product.name} vào giỏ hàng thành công`);
reset();
setQuantity(1);
};
Vậy một item trong giỏ có hai loại id hoàn toàn khác nhau:
| Trường | Nguồn | Vai trò |
|---|---|---|
id |
uuidv4() sinh ở frontend |
Khoá của DÒNG giỏ hàng, chỉ sống trong trình duyệt, không bao giờ gửi lên server |
productId |
product._id của MongoDB |
Khoá của SẢN PHẨM, dùng để gộp trùng và gửi lên POST /api/orderDetail |
Về thiết kế, đây KHÔNG hẳn là bug — một productId có thể xuất hiện ở nhiều dòng (khác
đá/đường), nên cần id riêng cho từng dòng. Nhưng hệ quả thực tế rất đáng lo:
- uuid bị vứt đi khoảng một nửa số lần. Mỗi lần bấm "Thêm vào giỏ", dòng 51 sinh uuid mới;
nếu
addCartphát hiện trùng thì nó chỉ cộngquantity(dòng 22) và uuid vừa sinh bị bỏ ngay. - uuid chỉ được sinh ở đúng 1 chỗ trong toàn dự án. Mai bạn thêm nút "Mua nhanh" ở
HomeProducts.jsmà quênid: uuidv4()→ item vào giỏ vớiid === undefined. Khi đóremoveItemCart(undefined)xoá sạch mọi dòng cóid === undefined, vàupdateQuantityghi đè nhầm dòng. idkhông bao giờ tới backend.CheckoutPage.jschỉ gửiproductId,quantity,ice,sugar… Người mới rất dễ tưởngidlà id trong database — không phải.idsống rất lâu, vì giỏ hàng được redux-persist lưu xuốnglocalStorage(Bài 21).
💡 Cách chuẩn hơn: đặt tên là
lineIdcho rõ nghĩa, và sinh nó ngay trongaddCart… nhưng khoan — reducer phải thuần, màuuidv4()là ngẫu nhiên! Đúng chỗ để làm làpreparecallback của RTK:addCart: { reducer(state, { payload }) { /* logic gộp */ }, prepare(product) { return { payload: { lineId: uuidv4(), ...product } }; }, }
preparechạy trước reducer nên được phép "không thuần".
7. useSelector và useDispatch
7.1. useSelector — ĐỌC state
yotea-fe/src/pages/layouts/WebsiteLayout.js:46-53
const categories = useSelector(selectCatesProduct);
const wishlist = useSelector(selectWishlist);
const isShowWishlist = useSelector(selectShowWishlist);
const isLogged = useSelector(selectStatusLoggin);
const { user } = useSelector(selectAuth);
const cart = useSelector(selectCart);
Cách hoạt động: useSelector nhận hàm selector (nhận state toàn cục, trả về mảnh bạn
cần). React-Redux đăng ký theo dõi store; mỗi khi store đổi nó chạy lại selector, và chỉ
re-render khi giá trị trả về khác lần trước (so sánh ===). Nhờ vậy WebsiteLayout không
re-render khi state.news hay state.contact đổi.
7.2. useDispatch — THAY ĐỔI state
yotea-fe/src/pages/user/cart/CartPage.js:101-107
const handleUpdateQnt = () => {
if (!disableBtnUpdate) {
dispatch(updateQuantity(cartQnt));
toast.success("Cập nhật thành công");
setDisableBtnUpdate(true);
}
};
Ba bước luôn giống nhau, học thuộc là dùng được cả dự án:
import { useDispatch } from "react-redux";
import { updateQuantity } from "../../../redux/cartSlice";
const dispatch = useDispatch(); // 1. lấy hàm dispatch (CartPage.js:17)
dispatch(updateQuantity(cartQnt)); // 2. gọi action creator → 3. nộp phiếu
⚠️ Lỗi kinh điển: viết
dispatch(updateQuantity)— thiếu ngoặc gọi hàm. Bạn đang nộp cái máy in phiếu thay vì tờ phiếu. Redux némActions must be plain objects.
7.3. Vì sao phải viết selector thay vì state.cart.cart khắp nơi?
const cart = useSelector((state) => state.cart.cart); // ❌ lặp lại ở 3 file
const cart = useSelector(selectCart); // ✅ dự án đang dùng
| Lý do | Giải thích |
|---|---|
| Một chỗ sửa duy nhất | selectCart được import ở 3 file (WebsiteLayout.js:37, CartPage.js:12, CheckoutPage.js:14). Đổi hình dạng state chỉ sửa 1 dòng trong cartSlice.js |
| Che giấu hình dạng state | Component không cần biết state lồng mấy tầng. state.cart.cart (hai chữ cart!) là hình dạng xấu, selector che nó đi |
| Đặt tên có nghĩa | selectAuth đọc là "lấy thông tin đăng nhập", dễ hơn state.auth.value |
| Chống gõ sai âm thầm | Gõ nhầm state.card.cart nổ lúc chạy mà không ai biết vì sao. Dùng selector thì chỉ có 1 nơi để gõ sai |
| Dọn đường cho memo hoá | Sau này cần tính toán nặng, chỉ việc bọc createSelector tại đúng chỗ đó |
Selector trong Yotea đều là một dòng, đặt cuối file slice.
yotea-fe/src/redux/authSlice.js:57-60
export const { signin, logout } = authSlice.actions;
export const selectStatusLoggin = (state) => state.auth.isLogged;
export const selectAuth = (state) => state.auth.value;
export default authSlice.reducer;
⚠️ Chỗ này dự án làm chưa chuẩn:
selectStatusLogginsai chính tả (thừa chữ "g"). Nó đã dùng ở 3 file nên giờ sửa là phải sửa cả 3 — bài học nhớ đời về việc đặt tên public API cẩn thận ngay từ đầu.
8. rootReducer — ghép 11 slice thành một cây state
yotea-fe/src/redux/rootReducer.js:19-38
const store = combineReducers({
cateProduct: cateProductReducer,
news: newsReducer,
cateNews: cateNewsReducer,
contact: contactReducer,
slider: sliderReducer,
product: productReducer,
store: storeReducer,
user: userReducer,
wishlist: wishlistReducer,
auth: authReducer,
cart: cartReducer,
[productApi.reducerPath]: productApi.reducer,
[sliderApi.reducerPath]: sliderApi.reducer,
[cateProductApi.reducerPath]: cateProductApi.reducer,
[userApi.reducerPath]: userApi.reducer,
[newsApi.reducerPath]: newsApi.reducer,
});
export default store;
Đọc từng dòng:
| Dòng | Code | Ý nghĩa |
|---|---|---|
| 19 | combineReducers({...}) |
Ghép nhiều reducer con thành một reducer tổng |
| 20 | cateProduct: cateProductReducer |
Key bên trái quyết định state nằm ở đâu → state.cateProduct |
| 30 | cart: cartReducer |
Vì cartSlice có initialState = { cart: [] } nên mới ra state.cart.cart |
| 31-35 | [productApi.reducerPath]: ... |
Computed key — gắn cache RTK Query vào store (Bài 22) |
| 38 | export default store |
Xuất reducer tổng cho store.js dùng |
Cơ chế rất đơn giản: mỗi action được gửi tới TẤT CẢ reducer con. Reducer nào "nhận ra" action type của mình thì xử lý, còn lại trả về state cũ nguyên vẹn.
8.1. State của app trông như thế nào?
| Đường dẫn | Kiểu | Nội dung |
|---|---|---|
state.cart.cart |
mảng | Món trong giỏ (selectCart) |
state.auth.isLogged / state.auth.value |
boolean / object | Đã đăng nhập chưa · { token, user } |
state.cateProduct.value |
mảng | Danh mục sản phẩm (selectCatesProduct) |
state.news.value / .totalItem |
mảng / số | Tin tức + tổng số |
state.cateNews.value |
mảng | Chuyên mục tin |
state.contact.value / .totalContact |
mảng / số | Liên hệ khách gửi |
state.slider.value |
mảng | Banner trang chủ |
state.product.value / .totalProduct |
mảng / số | Không ai đọc — code chết |
state.store.value / .totalStore |
mảng / số | Danh sách chi nhánh cửa hàng |
state.user.value / .totalUser |
mảng / số | Danh sách tài khoản (admin) |
state.wishlist.value / .showWishlist |
mảng / boolean | Yêu thích + cờ mở drawer |
state.productApi, state.sliderApi… |
object | Cache của RTK Query |
state._persist |
object | Do redux-persist thêm vào |
⚠️ Ba chữ "store" dễ gây loạn óc:
store.jslà file cấu hình; biếnstoreởrootReducer.js:19thực chất là root reducer (đặt tên sai ngữ nghĩa hoàn toàn); cònstate.storelà danh sách chi nhánh cửa hàng. Đọc code Yotea luôn phải hỏi "store nào?".
8.2. Điểm danh 11 slice của dự án
| # | File slice | Mount key | Một câu tóm tắt |
|---|---|---|---|
| 1 | authSlice.js |
auth |
Token + người đang đăng nhập; 2 reducer đồng bộ signin/logout + 2 thunk sửa hồ sơ. Quan trọng nhất dự án |
| 2 | cartSlice.js |
cart |
Giỏ hàng client-side, slice duy nhất không có thunk; được persist xuống localStorage |
| 3 | categoryProductSlice.js |
cateProduct |
CRUD danh mục sản phẩm; ⚠️ name: "categoryProduct" lệch với mount key |
| 4 | cateNewsSlice.js |
cateNews |
CRUD chuyên mục tin — gần như bản sao 1:1 của slice số 3 |
| 5 | contactSlice.js |
contact |
Chỉ đọc + xoá liên hệ (khách gửi qua form public, admin không thêm được) |
| 6 | newsSlice.js |
news |
CRUD tin tức; ⚠️ đặt tên totalItem thay vì totalNews, lệch quy ước |
| 7 | productSlice.js |
product |
☠️ 100% code chết — đã bị productApi (RTK Query) thay thế, không file nào import |
| 8 | sliderSlice.js |
slider |
CRUD banner trang chủ; ⚠️ trùng chức năng với sliderApi → hai cache lệch nhau |
| 9 | storeSlice.js |
store |
CRUD chi nhánh; ☠️ gần như chết — StoreList không được page nào render |
| 10 | userSlice.js |
user |
CRUD tài khoản cho admin; ⚠️ sửa user không đồng bộ sang state.auth |
| 11 | wishlistSlice.js |
wishlist |
Sản phẩm yêu thích + cờ showWishlist; 2 reducer đồng bộ + 3 thunk |
Nhận xét quan trọng: 8/11 slice dùng chung một khuôn duy nhất — initialState { value: [], total<X>: 0 }, thunk getX/addX/updateX/deleteX, reducers: {} luôn rỗng, selector
selectX/selectTotalX. Quy ước nhất quán này là điểm cộng lớn nhất của dự án, và là lý do
học xong 1 slice thì đọc được cả 11.
8.3. ⚠️ Store của dự án dùng API đã bị khai tử
yotea-fe/src/redux/store.js:30-36
export const store = createStore(
persistedReducer,
applyMiddleware(...middleware)
);
export default persistStore(store);
setupListeners(store.dispatch);
Dự án dùng createStore + applyMiddleware — API Redux đời cũ, đã deprecated từ Redux v5 —
trong khi đã cài sẵn @reduxjs/toolkit. Hậu quả bạn đụng ngay khi debug:
| Mất gì | Vì sao đau |
|---|---|
| Redux DevTools | Không xem được cây state, không xem lịch sử action, không "tua ngược". Debug Redux mà thiếu DevTools như sửa xe trong bóng tối |
serializableCheck |
Không ai cảnh báo khi lỡ nhét Date, Map, class instance vào state |
immutableCheck |
Lỡ mutate state ngoài reducer thì im lặng luôn |
actionCreatorCheck |
Không cảnh báo khi dispatch(actionCreator) thiếu ngoặc |
| Thunk sẵn có | Phải cài thêm redux-thunk và tự thêm vào mảng middleware (store.js:5,22) |
Cách viết đúng chỉ tốn chừng này dòng:
// ĐÂY LÀ CODE ĐÚNG — dự án CHƯA có, ta sẽ sửa thật ở Bài 34
export const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({ serializableCheck: false }).concat(
productApi.middleware,
sliderApi.middleware,
cateProductApi.middleware,
userApi.middleware,
newsApi.middleware
),
});
configureStore tự bật DevTools ở chế độ development, tự nạp redux-thunk, và tự bật
3 lớp kiểm tra. Ta sẽ refactor ở Bài 34; bài này giữ nguyên để bạn
vẫn chạy được dự án gốc.
9. 🛠️ Tự tay làm — viết toppingSlice.js
Mục tiêu: cuối phần này bạn có một slice Topping hoàn chỉnh (state + reducer đồng bộ + selector), đã gắn vào store, và
ToppingPageđọc dữ liệu từ Redux thay vìuseStatecục bộ.
Ở bài trước bạn đã viết src/api/topping.js để gọi API. Bài này ta làm tiếp: đưa dữ liệu
đó vào Redux để mọi component dùng chung.
📌 Toàn bộ code phần này là code bạn tự viết thêm — dự án gốc chưa có.
Bước 1 — Tạo file src/redux/toppingSlice.js
// yotea-fe/src/redux/toppingSlice.js ← file MỚI, bạn tự tạo
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
value: [],
};
const toppingSlice = createSlice({
name: "topping",
initialState,
reducers: {
setToppings(state, { payload }) {
state.value = payload;
},
},
});
export const selectToppings = (state) => state.topping.value;
export const { setToppings } = toppingSlice.actions;
export default toppingSlice.reducer;
Đối chiếu với cartSlice.js bạn vừa mổ xẻ:
| Phần | Ở cartSlice |
Ở toppingSlice của bạn |
|---|---|---|
name |
"cart" |
"topping" → action type là "topping/setToppings" |
initialState |
{ cart: [] } |
{ value: [] } — theo đúng quy ước 8 slice khác |
| reducer | 4 cái | 1 cái: setToppings |
| selector | selectCart |
selectToppings |
💡 Vì sao
{ value: [] }chứ không phải{ topping: [] }? VìcartSlicedùng{ cart: [] }nên mới đẻ rastate.cart.cartxấu xí. 8 slice còn lại đều dùngvalue— hãy theo số đông. State của bạn sẽ làstate.topping.value, đọc rõ nghĩa.
Bước 2 — Đăng ký vào rootReducer.js
Mở yotea-fe/src/redux/rootReducer.js, thêm 2 dòng (đây là ngoại lệ duy nhất bạn được
sửa file dự án trong mạch thực hành — không đăng ký thì slice vô dụng):
// yotea-fe/src/redux/rootReducer.js — dòng bạn TỰ THÊM
import toppingReducer from "./toppingSlice"; // đặt cạnh các import slice khác
const store = combineReducers({
cateProduct: cateProductReducer,
// ... giữ nguyên toàn bộ các dòng cũ ...
cart: cartReducer,
topping: toppingReducer, // ← DÒNG MỚI: quyết định state nằm ở state.topping
[productApi.reducerPath]: productApi.reducer,
// ... giữ nguyên phần còn lại ...
});
⚠️ Nhớ kỹ: key
toppingở đây phải khớp vớistate.topping.valuetrong selector. Sai một chữ làselectToppingstrảundefinedmà không có lỗi nào hiện ra — đúng cái bẫy ở mục 7.3.
Bước 3 — Dùng trong ToppingPage.js
Mở yotea-fe/src/pages/user/ToppingPage.js (tạo ở Bài 16), bỏ useState, chuyển sang Redux:
// yotea-fe/src/pages/user/ToppingPage.js — bạn TỰ SỬA
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { getAll } from "../../api/topping";
import { selectToppings, setToppings } from "../../redux/toppingSlice";
import ToppingCard from "../../components/user/ToppingCard";
import { updateTitle } from "../../utils";
const ToppingPage = () => {
const dispatch = useDispatch();
const toppings = useSelector(selectToppings); // ĐỌC từ store
useEffect(() => {
updateTitle("Topping");
const fetchToppings = async () => {
const { data } = await getAll();
dispatch(setToppings(data)); // GHI vào store
};
fetchToppings();
}, [dispatch]);
return (
<section className="container max-w-6xl mx-auto px-3 mb-8">
<h1 className="text-2xl font-semibold my-6">Danh sách topping</h1>
<div className="grid grid-cols-12 gap-4">
{toppings.map((topping) => (
<ToppingCard key={topping._id} topping={topping} />
))}
</div>
</section>
);
};
export default ToppingPage;
Đọc lại luồng cho chắc:
ToppingPage mount
→ useEffect chạy
→ getAll() gọi GET /api/topping (tầng API — Bài 18)
→ dispatch(setToppings(data)) (nộp phiếu)
→ reducer setToppings gán state.value = data
→ store đổi → useSelector trả về mảng mới
→ ToppingPage re-render, vẽ ra các ToppingCard
💡 Vì sao dependency là
[dispatch]chứ không phải[]?dispatchđược React-Redux đảm bảo ổn định (không đổi giữa các lần render) nên effect vẫn chỉ chạy 1 lần — nhưng ESLintreact-hooks/exhaustive-depssẽ không càu nhàu nữa.
10. ✅ Kiểm chứng kết quả
# terminal 1 — đứng tại thư mục yotea-be
npm start
# terminal 2 — đứng tại thư mục yotea-fe
npm start
Mở http://localhost:3000/topping — danh sách topping phải hiển thị y như trước, nhưng lần
này dữ liệu đi qua Redux.
Bước 1 — in state ra console. Vì dự án không có Redux DevTools (mục 8.3), ta kiểm chứng thô
sơ. Thêm tạm vào ToppingPage.js:
const toppings = useSelector(selectToppings);
console.log("state.topping.value =", toppings); // ← dòng TẠM để kiểm chứng
Mở F12 → tab Console, phải thấy đúng 2 lần in:
state.topping.value = [] ← lần render đầu, state rỗng
state.topping.value = (3) [{…}, {…}, {…}] ← sau khi dispatch(setToppings)
Bung một phần tử ra phải thấy đúng dữ liệu backend trả về:
{
"_id": "6650a1f2c4e8b91234abcd77",
"name": "Trân châu đen",
"price": 5000,
"slug": "tran-chau-den",
"createdAt": "2026-08-15T09:12:00.000Z"
}
Bước 2 — chứng minh "kho dùng chung". Thêm tạm vào WebsiteLayout.js (file hoàn toàn
khác, không nhận props gì từ ToppingPage):
const toppings = useSelector(selectToppings); // nhớ import selectToppings
console.log("Layout thấy", toppings.length, "topping");
Vào /topping rồi xem Console: WebsiteLayout cũng in ra đúng số topping, dù chẳng ai
truyền gì cho nó. Đó chính xác là điều Redux giải quyết — hết prop drilling.
Bước 3 — soi cả cây state. Thêm tạm window.store = store; vào yotea-fe/src/index.js, rồi
gõ trong Console:
store.getState().topping
// → { value: Array(3) }
Object.keys(store.getState())
// → ['cateProduct','news','cateNews','contact','slider','product','store',
// 'user','wishlist','auth','cart','topping','productApi',...,'_persist']
Thấy topping nằm cạnh 11 slice kia nghĩa là bạn đã đăng ký thành công.
Kiểm chứng xong nhớ xoá hết console.log và dòng window.store đi nhé.
11. 🐞 Lỗi thường gặp
| Thông báo lỗi | Nguyên nhân | Cách sửa |
|---|---|---|
Cannot read properties of undefined (reading 'value') |
Selector viết state.topping.value nhưng chưa đăng ký topping: trong rootReducer |
Thêm topping: toppingReducer vào combineReducers |
toppings.map is not a function |
payload truyền vào là cả response axios, không phải mảng |
dispatch(setToppings(data)) — nhớ bóc data ra trước |
Actions must be plain objects |
dispatch(setToppings) thiếu ngoặc gọi hàm |
Viết dispatch(setToppings(data)) |
could not find react-redux context value |
Component nằm ngoài <Provider store={store}> |
Kiểm tra yotea-fe/src/index.js |
Cannot return a new state and modify the draft |
Reducer vừa state.value = ... vừa return {...} |
Chọn một kiểu |
| Component không re-render dù state đã đổi | Mutate state ngoài reducer (Immer chỉ chạy trong reducer) | Luôn đi qua dispatch |
useSelector chạy vô hạn, app đơ |
Selector tạo mảng/object mới mỗi lần, ví dụ useSelector(s => s.topping.value.filter(...)) |
Trả về giá trị nguyên gốc rồi lọc trong component, hoặc dùng createSelector |
| Không thấy Redux DevTools | Dự án dùng createStore thay vì configureStore |
Xem mục 8.3 — sửa ở Bài 34 |
12. 📝 Bài tập
Bài 1. addCart so sánh bằng productId, còn removeItemCart lọc bằng item.id.
(a) item.id sinh ra ở đâu, bằng hàm gì? (b) Điều gì xảy ra nếu ai đó thêm nút "Mua nhanh" ở
trang chủ mà quên sinh id?
(a) Sinh bằng uuidv4() tại đúng một chỗ duy nhất trong toàn dự án:
yotea-fe/src/pages/user/ProductDetailPage.js:20:import { v4 as uuidv4 } from "uuid";
yotea-fe/src/pages/user/ProductDetailPage.js:51: id: uuidv4(),
(b) Item vào giỏ với id === undefined, kéo theo:
- Xoá sai hàng loạt:
removeItemCart(undefined)chạyfilter((item) => item.id !== undefined)→ mọi dòng cóid === undefinedbị xoá cùng lúc, dù người dùng chỉ bấm ✕ trên một dòng. - Sửa nhầm số lượng:
updateQuantitydùngfind((item) => item.id === cartItem.id)→ luôn tìm thấy dòngundefinedđầu tiên, nên gõ số cho dòng 3 lại sửa nhầm dòng 1. - Nghịch lý:
addCartvẫn chạy đúng vì nó gộp theo(productId, ice, sugar)— nên bug ẩn mình rất lâu, chỉ lộ khi người dùng bấm xoá.
Cách phòng: sinh id trong prepare callback của chính addCart (mục 6), để không nơi gọi
nào có thể "quên".
Bài 2. Viết thêm cho toppingSlice.js hai reducer đồng bộ: addTopping (thêm 1 topping vào
cuối mảng) theo kiểu mutate, và removeTopping (xoá theo _id) theo kiểu gán mới.
// yotea-fe/src/redux/toppingSlice.js — code bạn tự viết thêm
reducers: {
setToppings(state, { payload }) {
state.value = payload;
},
// kiểu MUTATE — giống cart.push(newProduct) ở cartSlice.js:20
addTopping(state, { payload }) {
state.value.push(payload);
},
// kiểu GÁN MỚI — giống cartSlice.js:26
removeTopping(state, { payload }) {
state.value = state.value.filter((item) => item._id !== payload);
},
},
Nhớ export: export const { setToppings, addTopping, removeTopping } = toppingSlice.actions;
Cả hai kiểu đều đúng nhờ Immer. Kiểu state.value = [...state.value, payload] cũng chạy — và đó
chính là cách 8 slice CRUD của dự án đang viết
(yotea-fe/src/redux/categoryProductSlice.js:52-54):
builder.addCase(addCate.fulfilled, (state, { payload }) => {
state.value = [...state.value, payload];
});
</details>
Bài 3. Nếu bạn đổi dòng cart: cartReducer trong rootReducer.js:30 thành
gioHang: cartReducer thì những gì sẽ hỏng, và cần sửa ở đâu?
Hỏng 2 thứ:
- Selector
selectCartvỡ.cartSlice.js:46viếtstate.cart.cart→ giờstate.cartlàundefined→Cannot read properties of undefined (reading 'cart'). Cả 3 nơi dùng (WebsiteLayout.js:53,CartPage.js:18,CheckoutPage.js:36) đều trắng trang. - redux-persist mất giỏ hàng.
store.js:16khaiwhitelist: ["auth", "cart"]— keycartkhông còn tồn tại nên giỏ hàng không được lưu nữa, F5 là mất sạch.
Cần sửa 2 chỗ: cartSlice.js:46 → state.gioHang.cart, và store.js:16 →
whitelist: ["auth", "gioHang"].
Bài học: mount key trong combineReducers là một hợp đồng ngầm với selector và với
redux-persist. Đây là loại lỗi TypeScript bắt được ngay, còn JavaScript thuần chỉ nổ lúc chạy.
Cũng vì thế mà categoryProductSlice.js:40 khai name: "categoryProduct" nhưng mount ở key
cateProduct là một quả bom hẹn giờ — may là selector đang viết đúng.
📌 Tóm tắt
- Redux sinh ra để diệt prop drilling — ví dụ sống là badge giỏ hàng ở
WebsiteLayoutnhưng dữ liệu lại được thêm từProductDetailPage, hai nhánh xa nhau trên cây component. - Ví von: store = kho, reducer = thủ kho (người duy nhất được sửa hàng), action = phiếu yêu cầu, dispatch = nộp phiếu, selector = phiếu tra cứu.
- Ba nguyên tắc: một nguồn sự thật duy nhất · state chỉ đọc · thay đổi chỉ qua hàm thuần.
createSlice({ name, initialState, reducers })tự sinh action type ("cart/addCart"), action creator và reducer — thay cho 3 file của Redux thuần.- Immer cho phép viết
cart.push(...)trong reducer mà vẫn bất biến — nhưng chỉ bên trong reducer củacreateSlice. useSelector(selector)để đọc,useDispatch()+dispatch(action())để ghi. Luôn viết selector thay vì rảistate.cart.cartkhắp nơi.combineReducersghép 11 slice; key bên trái quyết định state nằm ở đâu — sai key là selector trảundefinedtrong im lặng.- ⚠️ Dự án dùng
createStore(đã deprecated) thay choconfigureStore⇒ mất Redux DevTools và toàn bộ lớp kiểm tra khi phát triển. Sẽ sửa ở Bài 34.
Từ khoá tra cứu thêm: redux toolkit createSlice, immer produce draft, useSelector useDispatch,
combineReducers, redux three principles, reselect createSelector, configureStore vs createStore
➡️ Bài tiếp theo: 20 — createAsyncThunk và extraReducers — slice của
bạn hiện vẫn phải gọi API thủ công trong useEffect rồi mới dispatch. Bài sau ta gói cả việc
gọi API vào bên trong Redux, kèm luôn ba trạng thái pending / fulfilled / rejected.
All rights reserved