0

# 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ùng text-bg-dark (đổi cả nền lẫn chữ). Bản trên thống nhất dùng text-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:

  1. API trả về { message: "Không tìm thấy" } thay vì mảng
  2. NEXT_PUBLIC_API_URL là 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/
</details> <details> <summary>Trang trắng, console báo "Nothing was returned from render"</summary>

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.

</details> <details> <summary>Hydration failed / Text content does not match</summary>

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 localStorage khi 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>)
</details>

✅ 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ệu categoryId.


📚 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

⚠️ SWRConfig cần "use client". Nếu layout là server component, hãy bọc nó trong Providers (bài 13) thay vì trực tiếp trong layout.


📝 Bài tập

  1. 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>
    );
    
  2. Hiển thị lỗi chi tiết: khi error.status === 404 thì 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".

  3. Nút "Tải lại": dùng mutate từ SWR:

    const { data, mutate } = useSWR(...);
    <button onClick={() => mutate()}>Tải lại</button>
    
  4. Đo hiệu quả cache: mở tab Network, chuyển qua lại giữa / và /menu nhiều lần. Đếm số request thật tới /products. So sánh khi bật/tắt revalidateOnFocus.

  5. Thay topRating bằ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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí