Bài 18 — Tầng gọi API với axios
Phần 2 · Frontend React — Thời lượng ước tính: ~70 phút ⬅️ Bài trước: 17 — Tailwind CSS trong dự án · Bài sau: 19 — Redux Toolkit: slice, action, reducer, selector ➡️ 🏠 Mục lục
Ở Bài 17 bạn đã trang trí xong ToppingCard bằng Tailwind — nhìn thì đẹp, nhưng dữ liệu vẫn là mảng "giả" bạn tự gõ tay trong ToppingPage. Bài này ta làm tiếp việc quan trọng nhất: nối giao diện đó với API Topping mà chính bạn đã viết ở phần backend (Bài 07 → Bài 13).
🎯 Sau bài này bạn sẽ
- Hiểu vì sao dự án tách hẳn thư mục
src/api/thay vì gọiaxiosthẳng trong component. - Đọc hiểu
axios.create()vàbaseURL— 7 dòng "nhỏ mà có võ" của Yotea. - Phân biệt được axios và fetch, biết vì sao dự án chọn axios.
- Giải thích được vì sao mọi chỗ trong dự án đều viết
const { data } = await getAll(). - Thuộc bộ quy ước
DB_NAME/getAll/get/getById/search/add/update/remove. - Gắn được
Authorization: Bearer <token>và hiểu chiêu{ token, user } = isAuthenticate(). - Mổ xẻ được
isAuthenticate()— vì sao phảiJSON.parsehai lần. - Chỉ ra 4 điểm yếu nghiêm trọng của tầng API này và biết cách làm chuẩn.
- Tự viết được
src/api/topping.jsvà gọi nó từToppingPage.
📋 Cần chuẩn bị
- Đã hoàn thành Bài 17 — có sẵn
ToppingCard.jsvàToppingPage.js. - API Topping ở backend chạy được (thử Postman:
GET http://localhost:8080/api/toppings). - Backend chạy cổng 8080, frontend cổng 3000.
- Nhớ lại Bài 03 phần tham số mặc định và template literal.
1. Vì sao phải có riêng thư mục api/?
Hãy tưởng tượng bạn viết thẳng thế này trong component:
// ❌ Cách KHÔNG nên làm
useEffect(() => {
axios.get("http://localhost:8080/api/products/?_sort=createdAt&_order=desc")
.then((res) => setProducts(res.data));
}, []);
Chạy được. Nhưng Yotea có hơn 40 màn hình. Nếu màn hình nào cũng tự gõ URL như vậy:
| Vấn đề | Hậu quả thật |
|---|---|
Deploy lên https://api.yotea.vn |
Phải sửa hàng chục file, sót một chỗ là một màn hình trắng |
| Cùng câu query lặp ở 5 màn hình | Sửa logic sắp xếp → phải nhớ sửa đủ 5 chỗ |
| Gắn token | Copy-paste đoạn headers khắp nơi, dễ quên |
| Viết test | Muốn test component phải giả lập cả mạng |
Tách ra src/api/ giải quyết cả bốn:
Component / Redux slice ← chỉ biết gọi hàm getAll(), add()
↓ import
src/api/product.js ← nơi DUY NHẤT biết URL và query string
↓ import
src/api/instance.js ← nơi DUY NHẤT biết địa chỉ server
↓
axios → http://localhost:8080/api/...
Ba lợi ích cụ thể:
- Đổi
baseURLmột chỗ — sửainstance.jslà cả app đổi theo. - Tái sử dụng —
getAll()củaproduct.jsđược dùng ởProductPage,HomeProductsvà cảproductSlice: viết một lần, xài ba nơi. - Dễ test — muốn test
ProductPage, chỉ cần "giả" hàmgetAlltrả về mảng cứng, không cần server nào chạy.
📖 Thuật ngữ: lớp này gọi là data access layer (tầng truy cập dữ liệu). Nguyên tắc: component lo hiển thị, tầng api lo lấy dữ liệu, hai bên không lấn sân nhau.
2. Soi code thật: instance.js — 7 dòng
yotea-fe/src/api/instance.js:1-7
import axios from "axios";
const instance = axios.create({
baseURL: "http://localhost:8080/api",
});
export default instance;
Đọc từng dòng:
| Dòng | Code | Ý nghĩa |
|---|---|---|
| 1 | import axios from "axios" |
Nạp thư viện axios (bản ^1.6.8 khai báo trong package.json) |
| 3 | const instance = axios.create({ |
Tạo bản sao axios có cấu hình riêng, không đụng axios toàn cục |
| 4 | baseURL: "http://localhost:8080/api" |
Địa chỉ gốc — mọi URL sau này chỉ cần viết phần đuôi |
| 7 | export default instance |
Export mặc định → 13 file api khác viết import instance from "./instance" |
axios.create() trả về một "axios con" mang sẵn cấu hình. Nhờ đó:
instance.get("/products");
// axios tự ghép: http://localhost:8080/api + /products
Vì baseURL đã chứa /api, bạn không viết /api/products — sẽ thành /api/api/products và nhận 404.
💡 Vì sao dùng
axios.create()mà không setaxios.defaults? Cấu hình toàn cục sẽ áp lên mọi lời gọi, kể cả tới server khác. Dự án có một chỗ gọi thẳngaxioslên Cloudinary (yotea-fe/src/utils/index.js:7-10) — dùng instance riêng là cách sạch để "mỗi server một instance".
3. axios và fetch khác nhau chỗ nào?
fetch có sẵn trong trình duyệt, không cần cài. Vậy sao còn cài axios?
| Tiêu chí | fetch (có sẵn) |
axios (dự án dùng) |
|---|---|---|
| Tự parse JSON | ❌ Phải gọi await res.json() thêm bước |
✅ res.data đã là object |
| Lỗi 4xx / 5xx | ❌ Không ném lỗi, phải tự kiểm if (!res.ok) |
✅ Ném lỗi ngay, rơi vào catch |
baseURL |
❌ Không có, phải tự nối chuỗi | ✅ axios.create({ baseURL }) |
| Interceptor | ❌ Không có | ✅ instance.interceptors.request.use(...) |
| Gửi body JSON | Tự JSON.stringify + set Content-Type |
Tự làm hết |
| Kích thước | 0 KB | ~13 KB |
Cái bẫy lớn nhất của fetch:
// ❌ fetch: server trả 404 nhưng KHÔNG rơi vào catch
const res = await fetch("http://localhost:8080/api/khong-ton-tai");
const data = await res.json(); // 💥 nổ ở đây, lỗi khó hiểu
// ✅ axios: 404 rơi thẳng vào catch
try {
const { data } = await instance.get("/khong-ton-tai");
} catch (e) {
console.log(e.response.status); // 404 — đúng như mong đợi
}
💡 Chốt: hai điểm khiến axios thắng là tự parse JSON và ném lỗi khi 4xx/5xx — chúng khiến
try...catchhoạt động đúng trực giác.
4. Cấu trúc response — vì sao luôn viết const { data } = ...?
Câu hỏi mà 100% người mới đều vấp. Khi gọi const response = await instance.get("/products"), response không phải mảng sản phẩm, mà là object mô tả cả cuộc trao đổi HTTP:
{
data: [ { _id: "...", name: "Trà sữa trân châu", price: 35000 }, ... ], // ← thân phản hồi
status: 200,
statusText: "OK",
headers: { "content-type": "application/json; charset=utf-8", ... },
config: { url: "/products", method: "get", baseURL: "...", ... },
request: XMLHttpRequest { ... }
}
Dữ liệu thật nằm ở trường data. Nhờ destructuring (Bài 03) dự án viết gọn thành const { data } = await getAll();.
Xem code thật yotea-fe/src/pages/user/ProductByCate.js:13-20:
useEffect(() => {
const getIdCate = async () => {
const { data } = await get(slug);
setCateId(data._id);
updateTitle(`${data.name}`);
};
getIdCate();
}, [slug]);
Đọc từng dòng:
| Dòng | Code | Ý nghĩa |
|---|---|---|
| 13 | useEffect(() => { |
Chạy sau khi component vẽ xong lần đầu |
| 14 | const getIdCate = async () => { |
Phải bọc hàm async riêng, vì callback của useEffect không được là async |
| 15 | const { data } = await get(slug) |
Gọi API, bóc lấy phần thân phản hồi |
| 16 | setCateId(data._id) |
Đưa vào state → React vẽ lại |
| 18 | getIdCate(); |
Gọi hàm vừa định nghĩa (quên dòng này là không có gì xảy ra!) |
| 20 | }, [slug]); |
Chạy lại mỗi khi slug trên URL đổi |
⚠️ Ngoại lệ duy nhất:
getAvgStar(yotea-fe/src/api/rating.js:34) không trả về response mà trả về một con số. Chỗ gọi phải viếtconst star = await getAvgStar(id); quen tay gõconst { data } = ...sẽ nhậnundefined. Xem Bài 30.
5. Bộ quy ước tên lặp lại ở mọi file api
Mở bất kỳ file nào trong yotea-fe/src/api/ bạn sẽ thấy cùng một khuôn. Học thuộc khuôn này là đọc được cả 15 file.
5.1. Hằng số DB_NAME
yotea-fe/src/api/product.js:6
const DB_NAME = "products";
| File | DB_NAME |
URL sinh ra |
|---|---|---|
product.js |
"products" |
/api/products |
category.js |
"category" |
/api/category |
comment.js |
"comments" |
/api/comments |
store.js |
"store" |
/api/store |
user.js |
"users" |
/api/users |
⚠️ Chỗ này dự án đặt tên chưa chuẩn:
DB_NAMEnghe như "tên database" nhưng thực ra là tên tài nguyên trên URL — đặtRESOURCEmới đúng nghĩa. Ngoài ra số ít/số nhiều lộn xộn (productsnhưngcategory,commentsnhưngslider) vì FE phải chạy theo route BE đã lỡ đặt.
5.2. Bộ hàm chuẩn
| Tên hàm | Việc | HTTP | Dạng URL | Cần token? |
|---|---|---|---|---|
getAll(start, limit) |
Lấy danh sách | GET | /{DB_NAME}/?_sort=...&_order=... |
Không |
get(slug) |
Lấy 1 bản ghi | GET | /{DB_NAME}/{slug} |
Không |
getById(id) |
Lấy 1 bản ghi theo id | GET | /{DB_NAME}/{id} |
Không |
search(...) |
Tìm kiếm | GET | ?q=... hoặc ?name_like=... |
Không |
add(payload) |
Tạo mới | POST | /{DB_NAME}/{user._id} |
Có |
update(payload) |
Sửa | PUT | /{DB_NAME}/{payload._id}/{user._id} |
Có |
remove(id) |
Xoá | DELETE | /{DB_NAME}/{id}/{user._id} |
Có |
Nguyên tắc vàng: đọc thì không cần token, ghi thì phải có token.
5.3. Cách ghép query string
yotea-fe/src/api/category.js:7-16
export const getAll = (start = 0, limit = 0) => {
let url = `/${DB_NAME}/?_sort=createdAt&_order=desc`;
if (limit) url += `&_start=${start}&_limit=${limit}`;
return instance.get(url);
};
export const get = (slug) => {
const url = `/${DB_NAME}/${slug}`;
return instance.get(url);
};
Đọc từng dòng:
| Dòng | Code | Ý nghĩa |
|---|---|---|
| 7 | (start = 0, limit = 0) |
Tham số mặc định — gọi getAll() không truyền gì vẫn chạy |
| 8 | let url = ... |
Dùng let (không phải const) vì dòng dưới sẽ nối thêm |
| 9 | if (limit) url += ... |
Chỉ khi truyền limit mới nối phân trang; limit = 0 là falsy → bỏ qua |
| 10 | return instance.get(url) |
Trả về Promise, không await ở đây — để nơi gọi tự await |
| 13-16 | get(slug) |
Không nối thêm gì → dùng const |
Bản phức tạp hơn ở yotea-fe/src/api/product.js:8-17 — thêm sắp xếp tuỳ biến và _expand:
export const getAll = (
start = 0,
limit = 0,
sort = "createdAt",
order = "desc"
) => {
let url = `/${DB_NAME}/?_expand=categoryId&_sort=${sort}&_order=${order}`;
if (limit) url += `&_start=${start}&_limit=${limit}`;
return instance.get(url);
};
Gọi getAll(0, 8) sinh ra URL:
http://localhost:8080/api/products/?_expand=categoryId&_sort=createdAt&_order=desc&_start=0&_limit=8
_sort, _order, _start, _limit, _expand chính là bộ lọc query bạn đã tự cài ở backend trong Bài 09. Frontend chỉ việc ghép chuỗi.
Hàm tìm kiếm — yotea-fe/src/api/product.js:37-47:
export const search = (
start = 0,
limit = 0,
sort = "createdAt",
order = "desc",
keyword
) => {
let url = `/${DB_NAME}/?_sort=${sort}&_order=${order}&q=${keyword}`;
if (limit) url += `&_start=${start}&_limit=${limit}`;
return instance.get(url);
};
⚠️ Hai chỗ dự án làm chưa chuẩn ở hàm này:
- Thứ tự tham số ngược đời:
keyword(bắt buộc) đứng sau 4 tham số có mặc định → người gọi luôn phải truyền đủ 5 đối số, giá trị mặc định thành vô dụng. Quy tắc: tham số bắt buộc trước, tham số có mặc định sau.- Không
encodeURIComponent(keyword): gõtrà & sữa→ URL thành?q=trà & sữa&_sort=..., dấu&cắt query làm đôi →_sortbị hiểu sai. Đúng phải là`&q=${encodeURIComponent(keyword)}`.
Một biến thể tìm kiếm khác — yotea-fe/src/api/store.js:12-15:
export const search = (keyword) => {
const url = `/${DB_NAME}/?name_like=${keyword}&_sort=createdAt&_order=desc`;
return instance.get(url);
};
⚠️ Cùng tên
searchnhưng hai cơ chế khác nhau:product.searchdùngq=(BE dịch thành$text: { $search }),store.searchdùngname_like=(BE dịch thành regex). Hệ quả của việc không thống nhất quy ước từ đầu.
6. Gắn token: Authorization: Bearer <token>
Với ba hàm ghi, backend đòi tấm vé chứng minh đã đăng nhập — chính là JWT bạn tạo ở Bài 11, gửi lên qua header.
yotea-fe/src/api/product.js:59-66
export const add = (product, { token, user } = isAuthenticate()) => {
const url = `/${DB_NAME}/${user._id}`;
return instance.post(url, product, {
headers: {
Authorization: `Bearer ${token}`,
},
});
};
Đọc từng dòng:
| Dòng | Code | Ý nghĩa |
|---|---|---|
| 59 | (product, { token, user } = isAuthenticate()) |
Tham số 1 là dữ liệu gửi lên. Tham số 2: nếu người gọi không truyền, tự chạy isAuthenticate() rồi bóc token và user |
| 60 | `/${DB_NAME}/${user._id}` |
Nhét id người đăng nhập vào cuối đường dẫn — khớp route BE router.post("/products/:userId", ...) |
| 61 | instance.post(url, product, {...}) |
post nhận 3 đối số: URL, body, rồi mới tới config |
| 63 | Authorization: `Bearer ${token}` |
Chữ Bearer + một dấu cách + token; sai một ly là 401 |
Bản remove — yotea-fe/src/api/product.js:68-75:
export const remove = (id, { token, user } = isAuthenticate()) => {
const url = `/${DB_NAME}/${id}/${user._id}`;
return instance.delete(url, {
headers: {
Authorization: `Bearer ${token}`,
},
});
};
Khác biệt duy nhất: delete không có body, nên config là đối số thứ 2. Đây là bẫy hay gặp — nhầm chỗ thì header không được gửi, server trả 401.
| Method | Chữ ký axios | Config nằm ở |
|---|---|---|
instance.get(url, config) / instance.delete(url, config) |
2 đối số | thứ 2 |
instance.post/put/patch(url, body, config) |
3 đối số | thứ 3 |
6.1. Vì sao viết { token, user } = isAuthenticate() mà không gọi ở đầu file?
Ba đặc tính của tham số mặc định giải thích tất cả:
- Biểu thức mặc định được tính lại ở MỖI LẦN GỌI, không phải một lần lúc import. Nếu viết
const { token } = isAuthenticate();ở đầu file, nó chạy ngay lúc app khởi động — lúc đó chưa ai đăng nhập → crash trắng màn hình. - Nhờ vậy token luôn là token mới nhất: đăng nhập tài khoản khác thì lần gọi sau tự lấy token mới.
- Mặc định chỉ kích hoạt khi đối số là
undefined.add(data)✅, nhưngadd(data, null)❌ →TypeError: Cannot destructure property 'token' of 'null'.
🔒 Ghi chú bảo mật:
user._idbị nhét lên URL ở cả 3 hàm ghi → id người dùng xuất hiện trong access log của server, proxy, CDN. Cách chuẩn là backend tự đọc id từ token (token đã chứa sẵn_id). Chi tiết ở Bài 33.
7. Mổ xẻ utils/localStorage.js — hàm 3 dòng khó nhất dự án
yotea-fe/src/utils/localStorage.js:1-4
export const isAuthenticate = () => {
return JSON.parse(JSON.parse(localStorage.getItem("persist:root")).auth)
.value;
};
Ba dòng, nhưng có tới hai lần JSON.parse. Ta bóc từng bước:
| Bước | Biểu thức | Kết quả |
|---|---|---|
| 1 | localStorage.getItem("persist:root") |
Một chuỗi: {"auth":"{\"isLogged\":true,\"value\":{...}}","cart":"[]","_persist":"..."}. Đám \" là dấu hiệu chuỗi lồng trong chuỗi |
| 2 | JSON.parse(...) lần 1 |
Object { auth: '...', cart: '[]', _persist: '...' } — nhưng mỗi giá trị vẫn là chuỗi |
| 3 | .auth |
Lấy trường auth ra, vẫn đang là chuỗi JSON, chưa dùng được |
| 4 | JSON.parse(...) lần 2 |
Object state thật: { isLogged: true, value: { token: "eyJhbGci...", user: {...} } } |
| 5 | .value |
Đúng thứ ta cần: { token, user } |
Đối chiếu với yotea-fe/src/redux/authSlice.js:4-7:
const initialState = {
isLogged: false,
value: {},
};
Đúng vậy — value chính là nơi authSlice cất { token, user } sau khi đăng nhập thành công.
💡 Muốn nhìn tận mắt: DevTools → tab Application → Local Storage →
http://localhost:3000→ khoápersist:root.
7.1. Vì sao phải parse HAI lần?
Vì redux-persist serialize từng reducer một cách độc lập — nó không nén cả cây state thành một chuỗi, mà làm hai tầng:
state Redux redux-persist ghi xuống localStorage
{ {
auth: { ... }, → "auth": "<chuỗi JSON của auth>",
cart: [ ... ], → "cart": "<chuỗi JSON của cart>"
} } ← rồi đóng gói cả object này thành 1 chuỗi nữa
Thiết kế này có lý do: khi chỉ cart đổi, redux-persist chỉ serialize lại cart, không đụng auth — nhanh hơn nhiều. Cái giá là bạn đọc tay thì phải bóc hai tầng.
Danh sách reducer được lưu nằm ở yotea-fe/src/redux/store.js:13-17:
const persistConfig = {
key: "root",
storage,
whitelist: ["auth", "cart"],
};
key: "root" giải thích tên khoá persist:root; whitelist giải thích vì sao trong đó chỉ có hai trường. Chi tiết ở Bài 21.
8. ⚠️ Bốn vấn đề nghiêm trọng của tầng API này
⚠️ Bốn mục dưới đây đều đã kiểm chứng bằng cách đọc source. Bạn không sửa code dự án ở bài này — chỉ cần hiểu.
8.1. isAuthenticate() ném TypeError khi chưa đăng nhập
Đọc lại hàm: không hề có một câu if nào kiểm tra dữ liệu tồn tại.
| Tình huống | Chuyện gì xảy ra | Lỗi ném ra |
|---|---|---|
| Lần đầu vào web, redux-persist chưa kịp ghi | getItem trả null → JSON.parse(null) = null → null.auth |
TypeError: Cannot read properties of null (reading 'auth') |
| Đã vào web nhưng chưa login | value là {} → user là undefined → user._id |
TypeError: Cannot read properties of undefined (reading '_id') |
Vừa bấm đăng xuất (logout set value = {}) |
Y như trên | Y như trên |
Điểm chết người: đây là lỗi JavaScript đồng bộ, không phải lỗi HTTP 401. Nó nổ ngay tại chữ ký hàm / dòng dựng URL — trước cả khi request được gửi đi. Hệ quả: catch báo chung chung không phân biệt được "chưa đăng nhập" với "server chết"; không có cơ chế chuyển hướng về /login; gọi ngoài try/catch → màn hình trắng (dự án không có Error Boundary).
Cách viết đúng — đoạn này bạn tự viết thêm, dự án chưa có, chỉ để tham khảo:
export const isAuthenticate = () => {
try {
const root = localStorage.getItem("persist:root");
if (!root) return { token: null, user: null };
const auth = JSON.parse(JSON.parse(root).auth || "{}");
const { token = null, user = null } = auth.value || {};
return { token, user };
} catch {
return { token: null, user: null };
}
};
⚠️ Còn một lỗi đặt tên:
isAuthenticatenghe như trả vềtrue/false, nhưng thực ra trả về object{ token, user }. Tên đúng phải làgetAuth(). Tên hàm nói dối là nguồn bug lớn nhất trong một codebase.
8.2. http://localhost:8080/api bị hardcode ở 6 chỗ
Grep toàn bộ yotea-fe/src với từ khoá http://localhost:8080 cho đúng 6 kết quả:
| # | Vị trí | Ngữ cảnh |
|---|---|---|
| 1 | yotea-fe/src/api/instance.js:4 |
baseURL của axios instance — phục vụ ~95% request |
| 2 | yotea-fe/src/api/category.js:48 |
fetchBaseQuery({ baseUrl }) của cateProductApi |
| 3 | yotea-fe/src/api/news.js:60 |
fetchBaseQuery({ baseUrl }) của newsApi |
| 4 | yotea-fe/src/api/product.js:99 |
fetchBaseQuery({ baseUrl }) của productApi |
| 5 | yotea-fe/src/api/slider.js:47 |
fetchBaseQuery({ baseUrl }) của sliderApi |
| 6 | yotea-fe/src/api/user.js:57 |
fetchBaseQuery({ baseUrl }) của userApi |
Muốn deploy phải sửa tay 6 file; sót một file là một phần trang chết lặng lẽ.
Chi tiết dễ nhầm: instance.js viết baseURL (hoa cả ba chữ — cú pháp axios), 5 chỗ RTK Query viết baseUrl (chỉ hoa chữ U — cú pháp fetchBaseQuery). Gõ nhầm hoa/thường giữa hai thư viện không hề báo lỗi, chỉ khiến request bay sai địa chỉ. Gặp lại RTK Query ở Bài 22.
8.3. Không có interceptor → không xử lý tập trung được token hết hạn
instance.js chỉ 7 dòng, không đăng ký interceptor nào. Ba hệ quả:
- Đoạn
headers: { Authorization: ... }bị copy-paste 34 lần khắpapi/. Đổi cách gắn token → sửa 34 chỗ. - Khi token hết hạn backend trả 401, mỗi component tự
catchkiểu riêng → người dùng kẹt ở màn hình lỗi mà không biết phải đăng nhập lại. - Không refresh token, không retry, không có nơi log lỗi tập trung.
8.4. Cách làm chuẩn: biến môi trường + interceptor
Bước 1 — đưa địa chỉ server ra .env. Create React App hỗ trợ sẵn: biến bắt đầu bằng REACT_APP_ được nhúng vào bundle lúc build.
# yotea-fe/.env ← file MỚI, dự án chưa có
REACT_APP_API_URL=http://localhost:8080/api
Bước 2 — viết lại instance.js có interceptor. Đoạn dưới bạn tự viết thêm, dự án chưa có — chỉ để tham khảo, đừng sửa file thật:
import axios from "axios";
import { isAuthenticate } from "../utils/localStorage";
const instance = axios.create({
baseURL: process.env.REACT_APP_API_URL || "http://localhost:8080/api",
timeout: 10000,
});
// 1. Interceptor REQUEST — chạy TRƯỚC khi mỗi request bay đi
instance.interceptors.request.use(
(config) => {
const { token } = isAuthenticate();
if (token) {
config.headers.Authorization = `Bearer ${token}`; // gắn token MỘT chỗ duy nhất
}
return config; // BẮT BUỘC phải return
},
(error) => Promise.reject(error)
);
// 2. Interceptor RESPONSE — chạy SAU khi nhận phản hồi
instance.interceptors.response.use(
(response) => response, // 2xx: cho đi tiếp
(error) => {
const status = error.response?.status;
if (status === 401) {
localStorage.removeItem("persist:root"); // token hỏng/hết hạn → dọn sạch
window.location.href = "/login"; // đá về trang đăng nhập
}
if (status === 403) window.location.href = "/"; // không đủ quyền
return Promise.reject(error); // vẫn ném tiếp để component biết
}
);
export default instance;
Có interceptor rồi, mọi hàm ghi gọn lại còn một dòng:
export const add = (product) => instance.post(`/${DB_NAME}`, product);
💡 Ví von: interceptor giống anh bảo vệ ở cổng công ty — ai đi ra cũng được dán thẻ (request interceptor), ai vào mà giấy tờ sai thì bị chặn và hướng dẫn về quầy lễ tân (response interceptor). Bạn không phải dặn từng người một.
Ta sẽ refactor thật ở Bài 34.
9. 🛠️ Tự tay làm — viết src/api/topping.js
Mục tiêu: cuối phần này bạn có file
src/api/topping.jsđúng chuẩn dự án, vàToppingPagehiển thị dữ liệu Topping thật từ MongoDB thay vì mảng giả.
Bước 1 — Kiểm tra backend còn sống
# đứng tại thư mục yotea-be
npm start
Mở Postman gọi GET http://localhost:8080/api/toppings → phải nhận được mảng:
[
{ "_id": "6650bb01c4e8b91234abcd11", "name": "Trân châu đen", "price": 5000, "slug": "tran-chau-den" },
{ "_id": "6650bb01c4e8b91234abcd12", "name": "Thạch dừa", "price": 7000, "slug": "thach-dua" }
]
Nếu chưa có dữ liệu, gọi POST /api/toppings/:userId thêm vài món trước.
Bước 2 — Tạo file yotea-fe/src/api/topping.js
Toàn bộ file này bạn tự viết mới, dự án chưa có. Bám đúng khuôn của category.js và store.js vừa mổ xẻ.
// yotea-fe/src/api/topping.js ← file MỚI, bạn tự tạo
import { isAuthenticate } from "../utils/localStorage";
import instance from "./instance";
const DB_NAME = "toppings";
// ---- Nhóm ĐỌC: không cần token ----
export const getAll = (start = 0, limit = 0) => {
let url = `/${DB_NAME}/?_sort=createdAt&_order=desc`;
if (limit) url += `&_start=${start}&_limit=${limit}`;
return instance.get(url);
};
export const get = (slug) => {
const url = `/${DB_NAME}/${slug}`;
return instance.get(url);
};
// ---- Nhóm GHI: bắt buộc gắn token ----
export const add = (topping, { token, user } = isAuthenticate()) => {
const url = `/${DB_NAME}/${user._id}`;
return instance.post(url, topping, {
headers: {
Authorization: `Bearer ${token}`,
},
});
};
export const update = (topping, { token, user } = isAuthenticate()) => {
const url = `/${DB_NAME}/${topping._id}/${user._id}`;
return instance.put(url, topping, {
headers: {
Authorization: `Bearer ${token}`,
},
});
};
export const remove = (id, { token, user } = isAuthenticate()) => {
const url = `/${DB_NAME}/${id}/${user._id}`;
return instance.delete(url, {
headers: {
Authorization: `Bearer ${token}`,
},
});
};
Ba chỗ phải kiểm tra kỹ:
| Kiểm tra | Vì sao |
|---|---|
DB_NAME khớp route backend |
Nếu ở phần backend bạn đặt route /topping (số ít) thì sửa DB_NAME = "topping" |
post/put có 3 đối số, delete có 2 |
Đặt config sai chỗ → header không được gửi → 401 |
update dùng topping._id, remove dùng id |
update nhận cả object, remove chỉ nhận id |
Bước 3 — Gọi getAll trong ToppingPage
Mở yotea-fe/src/pages/user/ToppingPage.js (tạo ở Bài 16), xoá mảng giả, thay bằng useEffect + useState.
// yotea-fe/src/pages/user/ToppingPage.js ← file BẠN đã tạo ở bài 16, giờ sửa lại
import { useEffect, useState } from "react";
import { getAll } from "../../api/topping";
import ToppingCard from "../../components/user/ToppingCard";
import { updateTitle } from "../../utils";
const ToppingPage = () => {
const [toppings, setToppings] = useState([]); // mảng rỗng, KHÔNG để undefined
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
updateTitle("Topping");
const fetchToppings = async () => {
try {
const { data } = await getAll(); // bóc trường data ra khỏi response
setToppings(data);
} catch (err) {
setError("Không tải được danh sách topping");
console.log(err);
} finally {
setLoading(false);
}
};
fetchToppings();
}, []); // [] → chỉ chạy MỘT lần khi vào trang
if (loading) return <p className="text-center py-10">Đang tải...</p>;
if (error) return <p className="text-center py-10 text-red-500">{error}</p>;
return (
<section className="container max-w-6xl mx-auto px-3 py-8">
<h1 className="text-[#D9A953] font-semibold text-3xl text-center mb-6">Topping</h1>
<div className="grid grid-cols-12 gap-6">
{toppings.map((topping) => (
<ToppingCard key={topping._id} topping={topping} />
))}
</div>
</section>
);
};
export default ToppingPage;
Bốn điểm cần nhớ:
| Điểm | Giải thích |
|---|---|
useState([]) |
Khởi tạo mảng rỗng; để useState() thì lần vẽ đầu toppings là undefined → .map nổ ngay |
Hàm async bên trong useEffect |
Callback của useEffect không được là async (React cần nó trả về hàm dọn dẹp, không phải Promise) |
finally { setLoading(false) } |
Tắt loading dù thành công hay thất bại |
key={topping._id} |
React cần key duy nhất cho mỗi phần tử danh sách |
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 (route bạn thêm ở Bài 15). Bạn sẽ thấy chữ "Đang tải...", rồi lưới các thẻ ToppingCard với đúng những topping đã tạo bằng Postman.
Kiểm chứng bằng DevTools — bước quan trọng nhất của bài:
F12→ tab Network → lọc Fetch/XHR → nhấnF5.- Phải thấy đúng một dòng request:
| Cột | Giá trị mong đợi |
|---|---|
| Name | toppings/?_sort=createdAt&_order=desc |
| Status | 200 |
| Method | GET |
| Type | xhr |
- Bấm vào dòng đó → tab Headers → Request URL phải đúng:
http://localhost:8080/api/toppings/?_sort=createdAt&_order=desc
- Sang tab Response → phải là mảng JSON:
[
{ "_id": "6650bb01c4e8b91234abcd11", "name": "Trân châu đen", "price": 5000, "slug": "tran-chau-den" },
{ "_id": "6650bb01c4e8b91234abcd12", "name": "Thạch dừa", "price": 7000, "slug": "thach-dua" }
]
- Đối chiếu: số phần tử phải đúng bằng số topping bạn tạo ở phần backend, và thứ tự là mới nhất trước (vì
_sort=createdAt&_order=desc).
💡 Nếu tab Network không thấy request nào bay đi, gần như chắc chắn bạn quên gọi hàm
fetchToppings()sau khi định nghĩa nó.
11. 🐞 Lỗi thường gặp
| Thông báo lỗi | Nguyên nhân | Cách sửa |
|---|---|---|
Network Error |
Backend chưa chạy | cd yotea-be rồi npm start |
has been blocked by CORS policy |
Backend chưa bật CORS cho cổng 3000 | Kiểm tra app.use(cors()) — xem Bài 04 |
Request bay tới /api/api/toppings |
Viết instance.get("/api/toppings") |
Bỏ /api đi, baseURL đã có sẵn |
404 Not Found |
DB_NAME không khớp route backend |
So lại với yotea-be/src/routes/topping.js |
toppings.map is not a function |
data không phải mảng |
console.log(data) xem thật sự nhận được gì |
Cannot read properties of undefined (reading 'map') |
Khởi tạo useState() thay vì useState([]) |
Luôn khởi tạo đúng kiểu dữ liệu |
Cannot read properties of null (reading 'auth') |
Gọi add/update/remove khi chưa đăng nhập |
Đăng nhập trước — đúng lỗi mục 8.1 |
401 Unauthorized khi gọi add |
Thiếu header, hoặc đặt config sai vị trí ở delete |
Xem bảng "config nằm ở đối số thứ mấy" mục 6 |
403 / Bạn không có quyền truy cập |
Tài khoản không phải admin | Đăng nhập bằng tài khoản role: 1 |
Response 200 nhưng mảng rỗng [] |
Database chưa có topping nào | Tạo vài bản ghi bằng Postman |
| Request bay hai lần | React.StrictMode cố tình chạy effect 2 lần ở môi trường dev |
Bình thường; bản production chỉ chạy 1 lần |
12. 📝 Bài tập
Bài 1. Viết thêm hàm search(keyword) vào src/api/topping.js, tìm topping theo tên (dùng name_like giống store.js). Nhớ tránh cái bẫy encodeURIComponent ở mục 5.3.
Đoạn này bạn tự viết thêm, dự án chưa có:
// yotea-fe/src/api/topping.js ← thêm vào cuối file
export const search = (keyword) => {
const url = `/${DB_NAME}/?name_like=${encodeURIComponent(
keyword
)}&_sort=createdAt&_order=desc`;
return instance.get(url);
};
So với yotea-fe/src/api/store.js:12-15, bản của bạn tốt hơn nhờ encodeURIComponent. Thử search("trân châu & thạch"):
- Bản dự án →
?name_like=trân châu & thạch&_sort=...→ dấu&cắt query,_sortmất. - Bản của bạn →
?name_like=tr%C3%A2n%20ch%C3%A2u%20%26%20th%E1%BA%A1ch&_sort=...✅
Điều kiện để chạy: backend phải xử lý được name_like — nếu ở Bài 09 bạn đã cài bộ lọc query cho controller topping thì đã có sẵn.
Bài 2. Đoạn code sau bị lỗi, chạy sẽ trả về 401. Chỉ ra lỗi và sửa lại.
export const remove = (id, { token, user } = isAuthenticate()) => {
const url = `/${DB_NAME}/${id}/${user._id}`;
return instance.delete(url, {}, {
headers: {
Authorization: `Bearer ${token}`,
},
});
};
<details>
<summary>💡 Xem gợi ý & lời giải</summary>
Lỗi: người viết tưởng delete cũng có 3 đối số như post, nên chèn thêm {} làm "body". Thực tế instance.delete(url, config) chỉ nhận 2 đối số → {} bị axios hiểu là config, còn object chứa headers thật bị bỏ qua hoàn toàn. Request bay đi không có Authorization → 401.
Điểm nguy hiểm: axios không hề báo lỗi, code vẫn chạy, chỉ thiếu header. Kiểu bug tốn cả buổi để tìm.
Sửa — bỏ {} đi, giống hệt yotea-fe/src/api/product.js:68-75:
export const remove = (id, { token, user } = isAuthenticate()) => {
const url = `/${DB_NAME}/${id}/${user._id}`;
return instance.delete(url, {
headers: {
Authorization: `Bearer ${token}`,
},
});
};
Kiểm chứng: DevTools → Network → bấm vào request → tab Headers → mục Request Headers phải thấy dòng Authorization: Bearer eyJhbGci....
Bài 3. Với người dùng chưa đăng nhập, dòng nào trong hàm add (product.js:59-66) nổ đầu tiên, lỗi cụ thể là gì, và vì sao khối try/catch bọc quanh lời gọi API không phân biệt được lỗi này với lỗi mất mạng?
Nổ ở dòng 59 — ngay tại chữ ký hàm, chưa tới dòng dựng URL. Vì tham số mặc định được tính trước khi thân hàm chạy: isAuthenticate() gọi JSON.parse(localStorage.getItem("persist:root")); nếu khoá chưa tồn tại thì JSON.parse(null) cho ra null, rồi null.auth ném:
TypeError: Cannot read properties of null (reading 'auth')
Nếu đã vào web nhưng chưa login (value = {}), isAuthenticate() trả {} → user = undefined → lúc này dòng 60 mới nổ với TypeError: Cannot read properties of undefined (reading '_id').
Vì sao try/catch không phân biệt được? Vì cả ba nguyên nhân rơi vào cùng một catch:
- Chưa đăng nhập →
TypeError(lỗi JS đồng bộ, request chưa hề được gửi). - Mất mạng →
AxiosErrorvớierror.response === undefined. - Token hết hạn →
AxiosErrorvớierror.response.status === 401.
Ba cách xử lý khác nhau (đá về /login / bảo thử lại / đăng nhập lại), nhưng người dùng chỉ nhận đúng một câu "Có lỗi xảy ra" vô nghĩa. Cách phân biệt đúng:
catch (error) {
if (!error.response) toast.error("Không kết nối được máy chủ");
else if (error.response.status === 401) navigate("/login");
else toast.error(error.response.data?.message || "Có lỗi xảy ra");
}
Nhưng gốc rễ vẫn là hai thứ ở mục 8.4: sửa isAuthenticate để trả null thay vì ném lỗi, và thêm response interceptor để 401 được xử lý một chỗ.
Bài 4. Ngày mai bạn deploy Yotea, backend chuyển sang https://api.yotea.vn/api. Liệt kê chính xác những file phải sửa với code hiện tại, rồi viết lại instance.js dùng biến môi trường để lần sau chỉ sửa một chỗ.
Với code hiện tại phải sửa 6 file (grep http://localhost:8080 cho đúng 6 kết quả): api/instance.js:4, api/category.js:48, api/news.js:60, api/product.js:99, api/slider.js:47, api/user.js:57.
Cách làm chuẩn — đoạn này bạn tự viết thêm, dự án chưa có:
# yotea-fe/.env.development
REACT_APP_API_URL=http://localhost:8080/api
# yotea-fe/.env.production
REACT_APP_API_URL=https://api.yotea.vn/api
// yotea-fe/src/api/instance.js
import axios from "axios";
export const API_URL = process.env.REACT_APP_API_URL || "http://localhost:8080/api";
const instance = axios.create({ baseURL: API_URL });
export default instance;
Rồi 5 file RTK Query đổi thành baseQuery: fetchBaseQuery({ baseUrl: API_URL }) (nhớ import { API_URL } from "./instance";).
Ba lưu ý:
- Biến bắt buộc có tiền tố
REACT_APP_(quy ước của CRA), nếu không sẽ làundefined. - Phải khởi động lại
npm startsau khi sửa.env— CRA chỉ đọc lúc khởi động. .envđược nhúng thẳng vào bundle JavaScript ai cũng đọc được → tuyệt đối không để secret key, mật khẩu database trong đó.
Ta sẽ làm thật việc này ở Bài 34.
</details>📌 Tóm tắt
- Tách riêng
src/api/cho ba lợi ích: đổibaseURLmột chỗ, tái sử dụng, dễ test. axios.create({ baseURL })tạo một axios riêng cho server Yotea — chỉ 7 dòng trongyotea-fe/src/api/instance.js.- axios hơn
fetchở ba điểm quyết định: tự parse JSON, ném lỗi khi 4xx/5xx, có interceptor. - Response của axios là object
{ data, status, headers, config, request }— dữ liệu thật nằm ởdata, nên dự án luôn viếtconst { data } = await getAll(). - Quy ước xuyên suốt:
DB_NAME+ bộ hàmgetAll/get/getById/search/add/update/remove. Đọc không cần token, ghi phải có token. - Ba hàm ghi gắn header
Authorization: Bearer <token>và dùng chiêu tham số mặc định{ token, user } = isAuthenticate()— biểu thức này được tính lại ở mỗi lần gọi. isAuthenticate()phảiJSON.parsehai lần vì redux-persist serialize từng reducer thành một chuỗi JSON riêng, rồi mới gói cả object thành một chuỗi nữa.- Bốn điểm yếu cần nhớ:
isAuthenticate()némTypeErrorkhi chưa đăng nhập, 6 chỗ hardcodehttp://localhost:8080/api, không có interceptor nên không xử lý tập trung được token hết hạn, và 34 chỗ copy-paste header. Cách chuẩn:REACT_APP_API_URL+ axios interceptor.
Từ khoá tra cứu thêm: axios create instance, axios interceptors, axios vs fetch, Bearer token authorization header, default parameters javascript, redux-persist storage format, REACT_APP environment variables, useEffect fetch data
➡️ Bài tiếp theo: 19 — Redux Toolkit: slice, action, reducer, selector — useState trong ToppingPage chỉ dùng được cho một component; bài sau ta đưa danh sách topping lên kho chung để mọi màn hình cùng đọc được, bằng cách tự viết toppingSlice.js.
All rights reserved