# Bài 12 — Lấy dữ liệu với SWR
⬅️ Bài trước | Mục lục | Bài tiếp theo ➡️
🎯 Mục tiêu
- Tạo file
.env.local— bước bắt buộc mà source gốc thiếu - Hiểu SWR và vì sao nó tốt hơn
useEffect + fetch - Nối trang chủ, menu, chi tiết sản phẩm với API
- Xử lý lỗi CORS và các lỗi kết nối thường gặp
Sau bài này, website của bạn sẽ hiển thị dữ liệu thật từ database.
💻 1. Biến môi trường — bước bắt buộc
Vấn đề
Toàn bộ code frontend dùng 2 biến này:
useSWR(`${process.env.NEXT_PUBLIC_API_URL}/products`, fetcher)
<img src={`${process.env.NEXT_PUBLIC_IMA_URL}${product.image}`} />
Nhưng repo gốc không có file .env.local (nó bị .gitignore bỏ qua).
Nếu bạn clone về chạy ngay, process.env.NEXT_PUBLIC_API_URL sẽ là undefined
và URL trở thành:
undefined/products
→ Fetch thất bại, trang trắng, console báo lỗi khó hiểu.
Đây là lý do số 1 khiến người mới clone dự án về mà không chạy được.
Cách sửa
Tạo file frontend/.env.local:
NEXT_PUBLIC_API_URL=http://localhost:5000
NEXT_PUBLIC_IMA_URL=http://localhost:5000/img/
⚠️ Ba quy tắc quan trọng
1. Tiền tố NEXT_PUBLIC_ là bắt buộc
Next.js chỉ đưa biến có tiền tố này vào code chạy ở trình duyệt. Biến không có tiền tố chỉ dùng được ở phía server.
NEXT_PUBLIC_API_URL=... # ✅ Client đọc được
API_SECRET=... # ❌ Client KHÔNG đọc được (và đó là điều tốt)
🔒 Vì
NEXT_PUBLIC_*bị nhúng thẳng vào bundle JavaScript, ai cũng xem được qua DevTools. Tuyệt đối không để mật khẩu, API key bí mật vào đây.
2. Chú ý dấu / cuối
NEXT_PUBLIC_API_URL=http://localhost:5000 # KHÔNG có / cuối
NEXT_PUBLIC_IMA_URL=http://localhost:5000/img/ # CÓ / cuối
Vì code ghép chuỗi trực tiếp:
`${NEXT_PUBLIC_API_URL}/products` → http://localhost:5000/products ✅
`${NEXT_PUBLIC_IMA_URL}${image}` → http://localhost:5000/img/sp1.jpg ✅
Nếu thêm / vào API_URL → http://localhost:5000//products (2 dấu gạch, có thể lỗi).
Nếu bỏ / ở IMA_URL → http://localhost:5000/imgsp1.jpg (ảnh 404).
3. Phải khởi động lại server sau khi sửa .env.local
Next.js chỉ đọc biến môi trường lúc khởi động. Sửa file xong phải:
Ctrl + C rồi npm run dev lại.
Kiểm tra biến đã được nạp
Thêm tạm vào một component có "use client":
console.log("API:", process.env.NEXT_PUBLIC_API_URL);
console.log("IMG:", process.env.NEXT_PUBLIC_IMA_URL);
Mở Console (F12). Nếu thấy undefined → kiểm tra:
- File đặt đúng ở
frontend/.env.local(không phải thư mục gốc dự án) - Tên biến viết đúng, có tiền tố
NEXT_PUBLIC_ - Đã khởi động lại
npm run dev
Tạo file .env.example để chia sẻ
.env.local không nên commit lên Git. Thay vào đó tạo frontend/.env.example:
# Sao chép file này thành .env.local rồi điền giá trị thật
NEXT_PUBLIC_API_URL=http://localhost:5000
NEXT_PUBLIC_IMA_URL=http://localhost:5000/img/
File này có commit lên Git, để người khác biết cần cấu hình gì.
📚 2. SWR là gì?
SWR = Stale While Revalidate: "trả về dữ liệu cũ ngay, đồng thời đi lấy dữ liệu mới ở nền, có mới thì cập nhật".
So sánh với cách thủ công
// ❌ Cách thủ công — 20 dòng, và vẫn thiếu nhiều thứ
function Home() {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setIsLoading(true);
fetch("http://localhost:5000/products")
.then((res) => {
if (!res.ok) throw new Error("Lỗi mạng");
return res.json();
})
.then((data) => setData(data))
.catch((err) => setError(err))
.finally(() => setIsLoading(false));
}, []);
// Thiếu: cache, dedupe, retry, revalidate khi focus lại tab, race condition...
}
// ✅ Với SWR — 2 dòng, đầy đủ tính năng
const fetcher = (...args) => fetch(...args).then((res) => res.json());
const { data, error, isLoading } = useSWR("http://localhost:5000/products", fetcher);
SWR làm gì thêm cho bạn?
| Tính năng | Ý nghĩa |
|---|---|
| Cache | Hai component cùng gọi 1 URL → chỉ 1 request thật |
| Dedupe | Nhiều lần gọi trong 2 giây → gộp thành 1 |
| Revalidate on focus | Quay lại tab → tự lấy dữ liệu mới |
| Revalidate on reconnect | Có mạng lại → tự lấy dữ liệu mới |
| Retry | Lỗi thì tự thử lại theo chiến lược lùi dần |
| Chống race condition | Request cũ về sau không ghi đè request mới |
📚 3. Cách dùng SWR
"use client"
import useSWR from "swr";
const fetcher = (...args) => fetch(...args).then((res) => res.json());
export default function Home() {
const { data, error, isLoading, mutate } = useSWR(
`${process.env.NEXT_PUBLIC_API_URL}/products`,
fetcher
);
if (error) return <strong>Đã có lỗi</strong>;
if (isLoading) return <p>Loading...</p>;
return <div>{data.length} sản phẩm</div>;
}
Các giá trị trả về
| Tên | Ý nghĩa |
|---|---|
data |
Dữ liệu. undefined khi chưa tải xong |
error |
Lỗi nếu fetcher ném exception |
isLoading |
true khi đang tải lần đầu (chưa có cache) |
isValidating |
true khi đang tải, kể cả tải lại nền |
mutate |
Hàm để cập nhật cache thủ công / bắt lấy lại dữ liệu |
fetcher — hàm lấy dữ liệu
const fetcher = (...args) => fetch(...args).then((res) => res.json());
SWR gọi fetcher(key) với key là URL bạn truyền. Dùng rest+spread để
truyền lại nguyên vẹn (xem bài 02 mục 5).
⚠️ fetcher này không bắt lỗi HTTP
fetch(url).then((res) => res.json())
fetch không ném lỗi khi server trả về 404 hay 500 — nó chỉ ném lỗi
khi không kết nối được. Nên nếu backend trả về 404 với body
{"message":"Không tìm thấy"}, SWR coi đó là thành công và
data = { message: "Không tìm thấy" }.
Kết quả: component nhận data là object thay vì mảng, rồi data.map() crash với
data.map is not a function.
Fetcher chuẩn hơn:
const fetcher = async (url) => {
const res = await fetch(url);
if (!res.ok) {
const error = new Error("Lỗi khi tải dữ liệu");
error.status = res.status;
error.info = await res.json().catch(() => ({}));
throw error;
}
return res.json();
};
Tách fetcher ra file dùng chung
Source gốc khai báo lại fetcher trong mỗi file — lặp code không cần thiết.
Tạo frontend/lib/fetcher.js:
export const fetcher = async (url) => {
const res = await fetch(url);
if (!res.ok) {
const error = new Error("Lỗi khi tải dữ liệu");
error.status = res.status;
error.info = await res.json().catch(() => ({}));
throw error;
}
return res.json();
};
export const API = process.env.NEXT_PUBLIC_API_URL;
export const IMG = process.env.NEXT_PUBLIC_IMA_URL;
Dùng ở mọi nơi:
import useSWR from "swr";
import { fetcher, API } from "@/lib/fetcher";
const { data, error, isLoading } = useSWR(`${API}/products`, fetcher);
💻 4. Trang chủ hoàn chỉnh
frontend/app/(user)/page.js:
"use client"
import Link from "next/link";
import useSWR from "swr";
import Slider from "./component/slider";
import Productlist from "./component/productlist";
import { fetcher, API } from "@/lib/fetcher";
const BRANDS = [
{ img: "h1.png", name: "Jean Paul" },
{ img: "h2.png", name: "VERSACE" },
{ img: "h3.png", name: "VALENTINO" },
{ img: "h4.png", name: "Ralph Lauren" },
{ img: "h5.png", name: "Chloé" },
{ img: "h6.png", name: "BURBERRY" },
{ img: "h7.png", name: "NARCISO RODRIGUEZ" },
{ img: "h8.png", name: "GUCCI" },
{ img: "h9.png", name: "TOM FORD" },
];
export default function Home() {
const { data: productList, error, isLoading } = useSWR(`${API}/products`, fetcher);
const { data: topRating } = useSWR(`${API}/products/topRating`, fetcher);
if (error) return <strong className="d-block text-center my-5">Đã có lỗi khi tải dữ liệu</strong>;
if (isLoading) return <p className="text-center my-5">Loading...</p>;
return (
<>
<Slider />
<div className="d-flex justify-content-center flex-wrap my-5 gap-3">
{BRANDS.map((brand) => (
<Link
key={brand.img}
href="/menu"
className="text-center text-decoration-none text-dark fw-bold"
style={{ width: "100px" }}
>
<img style={{ width: "60px" }} src={`/img/${brand.img}`} alt={brand.name} />
<br />
{brand.name}
</Link>
))}
</div>
<h2 className="text-center">SẢN PHẨM NỔI BẬT</h2>
<Productlist data={topRating || productList} />
<br />
<h2 className="text-center">SẢN PHẨM BÁN CHẠY</h2>
<Productlist data={productList} />
</>
);
}
⚠️ So với source gốc
Source gốc hiển thị cùng một danh sách cho cả 2 tiêu đề:
<h2>SẢN PHẨM NỔI BẬT</h2>
<Productlist data={productList} />
<h2>SẢN PHẨM BÁN CHẠY</h2>
<Productlist data={productList} /> {/* ← y hệt phía trên */}
API /products/topRating đã có sẵn ở backend (bài 06) nhưng không được dùng.
Bản trên đã tận dụng nó cho mục "SẢN PHẨM NỔI BẬT".
💡
topRating || productList— nếu API topRating chưa trả về (hoặc lỗi), tạm dùng danh sách chung để trang không bị trống.
💻 5. Trang menu
frontend/app/(user)/menu/page.js
"use client"
import useSWR from "swr";
import Productlist from "../component/productlist";
import { fetcher, API } from "@/lib/fetcher";
export default function Menu() {
const { data: productlist, error, isLoading } = useSWR(`${API}/products`, fetcher);
if (error) return <strong>Đã có lỗi</strong>;
if (isLoading) return <p>Loading...</p>;
return <Productlist data={productlist} />;
}
frontend/app/(user)/menu/[id]/page.js
"use client"
import useSWR from "swr";
import Productlist from "../../component/productlist";
import { fetcher, API } from "@/lib/fetcher";
export default function MenuDetail({ params }) {
const { data: productlist, error, isLoading } = useSWR(
`${API}/products/byCategory/${params.id}`,
fetcher
);
if (error) return <strong>Đã có lỗi</strong>;
if (isLoading) return <p>Loading...</p>;
return <Productlist data={productlist} />;
}
frontend/app/(user)/menu/layout.js — sidebar
Layout này chứa sidebar lọc/sắp xếp. Phần Redux (dispatch(sortASC()), setMin...)
sẽ hoạt động sau bài 13 và 15. Tạm thời để nguyên phần gọi API danh mục:
"use client"
import Link from "next/link";
import useSWR from "swr";
import { usePathname } from "next/navigation";
import { fetcher, API } from "@/lib/fetcher";
export default function Layout({ children }) {
const { data: category, error, isLoading } = useSWR(`${API}/categories`, fetcher);
const pathname = usePathname();
if (error) return <strong>Đã có lỗi</strong>;
if (isLoading) return <p>Loading...</p>;
return (
<div className="container mt-5">
<h2>Menu</h2>
<div className="row">
<div className="col-sm-3">
<div className="card">
<div className="card-header">
<h5 className="mb-0">Danh Mục</h5>
</div>
<ul className="list-group list-group-flush">
<Link
className={`list-group-item list-group-item-action ${
pathname === "/menu" ? "text-bg-dark" : ""
}`}
href="/menu"
>
Tất cả
</Link>
{category.map((item) => (
<Link
key={item._id}
className={`list-group-item list-group-item-action ${
pathname === "/menu/" + item._id ? "text-bg-dark" : ""
}`}
href={`/menu/${item._id}`}
>
{item.name}
</Link>
))}
</ul>
</div>
{/* Bài 15 sẽ thêm khối Sắp Xếp và Khoảng Giá ở đây */}
</div>
<div className="col-sm-9 bg-light">{children}</div>
</div>
</div>
);
}
⚠️ Source gốc dùng
bg-dark(chỉ đổi nền, chữ vẫn đen → không đọc được) cho mục danh mục đang chọn, trong khi mục "Tất cả" lại dùngtext-bg-dark(đổi cả nền lẫn chữ). Bản trên thống nhất dùngtext-bg-dark.
💻 6. Trang chi tiết sản phẩm
frontend/app/(user)/product/[id]/page.js:
"use client"
import Link from "next/link";
import useSWR from "swr";
import { useState } from "react";
import AddCardbutton from "../../component/addcardbuton";
import { fetcher, API, IMG } from "@/lib/fetcher";
export default function Productdetail({ params }) {
const { data: product, error, isLoading } = useSWR(
`${API}/products/id/${params.id}`,
fetcher
);
const [quantity, setQuantity] = useState(1);
const [size, setSize] = useState("s");
if (error) return <strong className="d-block text-center my-5">Không tìm thấy sản phẩm</strong>;
if (isLoading) return <div className="text-center my-5">Loading...</div>;
const fullStars = Math.floor(product.rating || 0);
return (
<div className="container mt-5">
<div className="row">
<div className="col-md-5">
<img src={`${IMG}${product.image}`} alt={product.name} className="w-100" />
</div>
<div className="col-md-7">
<h1>{product.name}</h1>
{product.category && (
<Link className="text-decoration-none" href={`/menu/${product.category._id}`}>
Danh Mục: {product.category.name}
<img
style={{ width: 40 }}
src={`${IMG}${product.category.image}`}
alt={product.category.name}
/>
</Link>
)}
<div className="text-warning">
{product.rating}
{[...Array(fullStars)].map((_, i) => (
<i key={`full-${i}`} className="fa-solid fa-star"></i>
))}
{[...Array(5 - fullStars)].map((_, i) => (
<i key={`empty-${i}`} className="fa-regular fa-star"></i>
))}
</div>
<div className="display-4">{product.price.toLocaleString("vi-VN")}đ</div>
<div className="row">
<div className="col-6">
Size:
<select
value={size}
onChange={(e) => setSize(e.target.value)}
className="form-select"
>
<option value="s">S</option>
<option value="m">M (+15.000đ)</option>
<option value="l">L (+10.000đ/sp)</option>
</select>
</div>
<div className="col-6">
Số Lượng:
<input
value={quantity}
onChange={(e) => setQuantity(Math.max(1, Number(e.target.value) || 1))}
type="number"
min="1"
className="form-control"
/>
</div>
</div>
<AddCardbutton
className="mt-3"
product={product}
quantity={quantity}
size={size}
>
Thêm Vào Giỏ Hàng
</AddCardbutton>
<p className="mt-3" style={{ whiteSpace: "pre-line" }}>
{product.description}
</p>
</div>
</div>
</div>
);
}
⚠️ Sáu lỗi đã sửa so với source gốc
| # | Lỗi gốc | Đã sửa |
|---|---|---|
| 1 | 2 nút "thêm vào giỏ" trùng nhau | Chỉ giữ 1 <AddCardbutton> |
| 2 | href={/categpry/${product.categoryID}} — sai chính tả "categpry" và sai tên biến (categoryID vs categoryId) |
href={/menu/${product.category._id}} |
| 3 | if(error) return (console.log('có lỗi xảy ra')) — trả về undefined → React crash |
Trả về JSX thật |
| 4 | Thiếu key khi map ra icon sao |
Thêm key={full-${i}} |
| 5 | Input số lượng dùng defaultValue (uncontrolled), gõ chữ vào cũng nhận |
Dùng value + ép về số ≥ 1 |
| 6 | description có \n nhưng HTML nuốt hết xuống dòng |
Thêm whiteSpace: "pre-line" |
Về lỗi 3 — đây là bug nghiêm trọng:
if (error) return (console.log('có lỗi xảy ra'))
console.log() trả về undefined, nên component return undefined.
React báo lỗi: Nothing was returned from render. Người dùng thấy trang trắng.
🆘 7. Xử lý lỗi CORS
Đây là lỗi phổ biến nhất khi nối frontend với backend.
Thông báo lỗi
Access to fetch at 'http://localhost:5000/products' from origin 'http://localhost:3000'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.
Nguyên nhân
Trình duyệt có chính sách Same-Origin Policy: JavaScript ở origin này không được đọc phản hồi từ origin khác, trừ khi server cho phép.
http://localhost:3000 và http://localhost:5000 là 2 origin khác nhau (khác port).
Cách sửa
Kiểm tra backend/app.js có 2 dòng này chưa:
var cors = require("cors");
app.use(cors());
Quan trọng: app.use(cors()) phải đặt TRƯỚC mọi app.use(...router).
Kiểm tra CORS đang hoạt động
Mở DevTools → tab Network → bấm vào request /products → tab Headers.
Trong phần Response Headers phải có:
access-control-allow-origin: *
Nếu không có → backend chưa bật CORS, hoặc bạn đang xem cache cũ (tick "Disable cache").
🆘 8. Các lỗi thường gặp khác
<details> <summary>Failed to fetch / ERR_CONNECTION_REFUSED</summary>Backend chưa chạy. Mở terminal khác:
cd backend
npm run dev
Kiểm tra bằng cách mở thẳng http://localhost:5000/products trong trình duyệt.
</details> <details> <summary>data.map is not a function</summary>data không phải mảng. Nguyên nhân thường gặp:
- API trả về
{ message: "Không tìm thấy" }thay vì mảng NEXT_PUBLIC_API_URLlàundefined→ fetch tới URL sai
Cách debug: console.log(data) ngay trước dòng .map() để xem thực tế là gì.
Cách phòng thủ:
const list = Array.isArray(data) ? data : [];
</details>
<details>
<summary>Ảnh không hiển thị (icon vỡ)</summary>
Bấm chuột phải vào ảnh → "Open image in new tab" để xem URL thực tế.
| URL thấy được | Nguyên nhân |
|---|---|
undefinedsp1.jpg |
NEXT_PUBLIC_IMA_URL chưa được nạp → khởi động lại npm run dev |
http://localhost:5000/imgsp1.jpg |
Thiếu / cuối trong IMA_URL |
http://localhost:5000/img/sp1.jpg nhưng 404 |
File không có trong backend/public/img/ |
Một component không có return, hoặc return một giá trị không phải JSX
(như console.log(...)). Xem lại mục 6 lỗi số 3.
HTML render ở server khác với HTML render ở client. Nguyên nhân hay gặp:
- Dùng
Date.now(),Math.random()trực tiếp trong JSX - Đọc
localStoragekhi render (Redux persist gây ra — bài 14 sẽ xử lý) - HTML không hợp lệ (VD:
<div>bên trong<p>,<Link>trực tiếp trong<ul>)
✅ 9. Kiểm thử
Chạy cả hai server:
# Terminal 1
cd backend && npm run dev # → port 5000
# Terminal 2
cd frontend && npm run dev # → port 3000
Kiểm tra từng trang:
| URL | Phải thấy |
|---|---|
http://localhost:3000/ |
Carousel + 9 logo + 2 lưới sản phẩm có ảnh, tên, giá |
http://localhost:3000/menu |
Sidebar 10 danh mục + lưới 12 sản phẩm |
http://localhost:3000/menu/669a978e6e282b058a8e3f2c |
Chỉ sản phẩm Gucci |
http://localhost:3000/product/<id> |
Ảnh lớn, tên, giá, mô tả, chọn size/số lượng |
Kiểm tra Console (F12) — không có lỗi đỏ.
Kiểm tra tab Network — các request tới localhost:5000 đều trả 200.
❗ Nếu
/menu/<id>hiện "Không có sản phẩm nào" → xem lại bài 03 mục 6 về kiểu dữ liệucategoryId.
📚 10. Cấu hình SWR toàn cục (tùy chọn)
Thay vì truyền fetcher ở mọi nơi, có thể cấu hình một lần:
// frontend/app/(user)/layout.js
import { SWRConfig } from "swr";
import { fetcher } from "@/lib/fetcher";
<SWRConfig value={{
fetcher,
revalidateOnFocus: false, // không tự tải lại khi quay lại tab
dedupingInterval: 5000, // gộp request trùng trong 5 giây
errorRetryCount: 2, // thử lại tối đa 2 lần
}}>
{children}
</SWRConfig>
Sau đó chỉ cần:
const { data } = useSWR(`${API}/products`); // không cần truyền fetcher
⚠️
SWRConfigcần"use client". Nếu layout là server component, hãy bọc nó trongProviders(bài 13) thay vì trực tiếp trong layout.
📝 Bài tập
-
Thêm skeleton loading thay cho chữ "Loading...":
if (isLoading) return ( <div className="row row-cols-4 g-3"> {[...Array(8)].map((_, i) => ( <div key={i} className="col"> <div className="card placeholder-glow" style={{ height: 350 }}> <span className="placeholder col-12" style={{ height: 200 }}></span> </div> </div> ))} </div> ); -
Hiển thị lỗi chi tiết: khi
error.status === 404thì hiện "Sản phẩm không tồn tại", còn lại hiện "Lỗi kết nối server". -
Nút "Tải lại": dùng
mutatetừ SWR:const { data, mutate } = useSWR(...); <button onClick={() => mutate()}>Tải lại</button> -
Đo hiệu quả cache: mở tab Network, chuyển qua lại giữa
/và/menunhiều lần. Đếm số request thật tới/products. So sánh khi bật/tắtrevalidateOnFocus. -
Thay
topRatingbằng "sản phẩm mới nhất": viết API mới ở backend (sort({ createdAt: -1 })) rồi dùng cho mục "SẢN PHẨM BÁN CHẠY".
⬅️ Bài trước | Mục lục | Bài tiếp theo: Redux Toolkit & giỏ hàng ➡️
All rights reserved