0

# Bài 18 — Formik & form upload sản phẩm

⬅️ Bài trước | Mục lục | Bài tiếp theo ➡️


🎯 Mục tiêu

  • Hiểu Formik giải quyết vấn đề gì
  • Dùng useFormik để quản lý form nhiều trường
  • Validate form với Yup
  • Gửi file lên server bằng FormData
  • Xem trước ảnh trước khi upload

Đây là bài cuối của phần bám sát source gốc. Từ bài 19 là phần mở rộng.


📚 1. Vấn đề: form nhiều trường rất rườm rà

Form thêm sản phẩm có 5 trường: name, description, categoryId, price, image.

Với useState thuần:

const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [categoryId, setCategoryId] = useState("");
const [price, setPrice] = useState(0);
const [image, setImage] = useState(null);

const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [submitting, setSubmitting] = useState(false);

<input value={name} onChange={(e) => setName(e.target.value)} />
<input value={price} onChange={(e) => setPrice(e.target.value)} />
// ... lặp lại cho từng trường

8 state, 5 handler gần như giống hệt nhau. Thêm 1 trường = sửa 4 chỗ.

Formik gom tất cả vào một object và cung cấp sẵn handler dùng chung.


📚 2. Formik cơ bản

import { useFormik } from "formik";

const formik = useFormik({
  initialValues: { name: "", price: 0 },
  onSubmit: (values) => {
    console.log(values);   // { name: "...", price: 123 }
  },
});

<form onSubmit={formik.handleSubmit}>
  <input
    name="name"                        // ← BẮT BUỘC, Formik dùng để biết trường nào
    value={formik.values.name}
    onChange={formik.handleChange}
  />
  <button type="submit">Gửi</button>
</form>

formik cung cấp gì?

Thuộc tính Ý nghĩa
values Object chứa giá trị tất cả các trường
errors Object chứa lỗi validate
touched Trường nào người dùng đã chạm vào (để chỉ hiện lỗi sau khi rời ô)
handleChange Handler dùng chung cho mọi input
handleBlur Đánh dấu trường đã được chạm
handleSubmit Gắn vào onSubmit của form
setFieldValue(name, value) Đặt giá trị thủ công (cần cho file input)
isSubmitting true khi đang submit
resetForm() Xóa hết về giá trị ban đầu

name là chìa khóa

<input name="price" onChange={formik.handleChange} />

handleChange đọc e.target.name để biết cập nhật trường nào trong values. Quên name → nhập gì cũng không lưu được.


💻 3. Form thêm sản phẩm — bám sát source gốc

frontend/app/(admin)/admin/product/add/page.js:

"use client"
import { useFormik } from "formik";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import useSWR from "swr";
import { fetcher, API } from "@/lib/fetcher";

export default function Productadd() {
  const { data: categorylist, error, isLoading } = useSWR(`${API}/categories`, fetcher);
  const router = useRouter();

  const formik = useFormik({
    initialValues: {
      name: "",
      description: "",
      categoryId: "",
      price: 0,
      image: null,
    },
    onSubmit: async (values) => {
      const formData = new FormData();
      formData.append("name", values.name);
      formData.append("description", values.description);
      formData.append("categoryId", values.categoryId);
      formData.append("price", values.price);
      formData.append("image", values.image);

      try {
        const res = await fetch(`${API}/products`, {
          method: "POST",
          body: formData,
        });
        const result = await res.json();
        if (!res.ok) {
          alert(result.message || "Thêm sản phẩm thất bại");
          return;
        }
        alert("Thêm sản phẩm thành công");
        router.push("/admin/product");
      } catch (err) {
        alert("Không kết nối được tới máy chủ");
      }
    },
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div className="alert alert-danger">Lỗi: {error.message}</div>;

  return (
    <>
      <div className="d-flex justify-content-between">
        <h3 className="mb-4">Thêm sản phẩm</h3>
        <Link href="/admin/product" className="btn btn-outline-secondary rounded-0">
          <i className="fas fa-arrow-left me-1"></i> Quay lại
        </Link>
      </div>

      <form className="row" onSubmit={formik.handleSubmit}>
        {/* ===== Cột trái ===== */}
        <div className="col-md-8 mb-4">
          <div className="card rounded-0 border-0 shadow-sm mb-4">
            <div className="card-body">
              <h6 className="pb-3 border-bottom">Thông tin cơ bản</h6>

              <div className="mb-3">
                <label htmlFor="name" className="form-label">Tên sản phẩm *</label>
                <input
                  type="text"
                  className="form-control rounded-0"
                  id="name"
                  name="name"
                  required
                  value={formik.values.name}
                  onChange={formik.handleChange}
                />
              </div>

              <div className="mb-3">
                <label htmlFor="description" className="form-label">Mô tả</label>
                <textarea
                  className="form-control rounded-0"
                  id="description"
                  name="description"
                  rows="6"
                  value={formik.values.description}
                  onChange={formik.handleChange}
                ></textarea>
              </div>

              <div className="mb-3">
                <label htmlFor="categoryId" className="form-label">Danh mục *</label>
                <select
                  className="form-select rounded-0"
                  id="categoryId"
                  name="categoryId"
                  required
                  value={formik.values.categoryId}
                  onChange={formik.handleChange}
                >
                  <option value="">-- Chọn danh mục --</option>
                  {categorylist.map((item) => (
                    <option key={item._id} value={item._id}>{item.name}</option>
                  ))}
                </select>
              </div>
            </div>
          </div>

          <div className="card rounded-0 border-0 shadow-sm">
            <div className="card-body">
              <h6 className="pb-3 border-bottom">Giá bán</h6>
              <div className="mb-3">
                <label htmlFor="price" className="form-label">Giá (VNĐ) *</label>
                <input
                  type="number"
                  className="form-control rounded-0"
                  id="price"
                  name="price"
                  min="0"
                  step="1000"
                  required
                  value={formik.values.price}
                  onChange={formik.handleChange}
                />
              </div>
            </div>
          </div>
        </div>

        {/* ===== Cột phải ===== */}
        <div className="col-md-4 mb-4">
          <div className="card rounded-0 border-0 shadow-sm">
            <div className="card-body">
              <h6 className="pb-3 border-bottom">Hình ảnh</h6>
              <div className="mb-3">
                <label htmlFor="image" className="form-label">Ảnh sản phẩm *</label>
                <input
                  className="form-control rounded-0"
                  type="file"
                  id="image"
                  name="image"
                  accept="image/*"
                  required
                  onChange={(event) => {
                    formik.setFieldValue("image", event.currentTarget.files[0]);
                  }}
                />
              </div>
            </div>
          </div>

          <button
            type="submit"
            className="btn btn-primary btn-lg rounded-0 mt-4 w-100"
            disabled={formik.isSubmitting}
          >
            {formik.isSubmitting ? "Đang lưu..." : "Tạo sản phẩm"}
          </button>
        </div>
      </form>
    </>
  );
}

🔍 4. Giải thích các điểm quan trọng

File input phải dùng setFieldValue

<input
  type="file"
  onChange={(event) => {
    formik.setFieldValue("image", event.currentTarget.files[0]);
  }}
/>

Vì sao không dùng formik.handleChange?

handleChange đọc e.target.value. Với <input type="file">, value là một chuỗi giả như "C:\fakepath\anh.jpg" — không phải file thật.

File thật nằm ở e.target.files (một FileList). Ta lấy phần tử đầu: event.currentTarget.files[0].

⚠️ File input không thể là controlled. Không được viết value={formik.values.image} — trình duyệt cấm gán giá trị cho input file (lý do bảo mật). Nó luôn uncontrolled.

FormData — đóng gói dữ liệu để upload

const formData = new FormData();
formData.append("name", values.name);
formData.append("image", values.image);   // File object

fetch(url, { method: "POST", body: formData });

Ba quy tắc quan trọng:

  1. KHÔNG đặt header Content-Type thủ công

    // ❌ SAI
    fetch(url, {
      method: "POST",
      headers: { "Content-Type": "multipart/form-data" },
      body: formData
    });
    

    multipart/form-data cần kèm chuỗi boundary ngẫu nhiên. Trình duyệt tự sinh khi bạn để trống. Đặt thủ công → thiếu boundary → Multer không parse được và req.body rỗng.

  2. Tên key phải khớp với backend

    formData.append("image", values.image);       // frontend
    upload.single("image")                        // backend — cùng chữ "image"
    

    Sai tên → Multer báo LIMIT_UNEXPECTED_FILE.

  3. Mọi giá trị đều thành chuỗi — kể cả số. Đó là lý do backend phải Number(req.body.price) (bài 07).

accept="image/*"

Lọc sẵn ở hộp thoại chọn file — người dùng chỉ thấy file ảnh. Đây là tiện ích, không phải bảo mật (vẫn chọn được file khác nếu cố tình). Validate thật vẫn phải ở backend.


⚠️ 5. Bảy lỗi trong source gốc

Lỗi 1 — Không xử lý kết quả trả về

// GỐC
try {
  fetch(`${API}/products`, { method: "POST", body: formData })
    .then((response) => {          // ← thân hàm RỖNG
    });
} catch (error) {
  console.log(error);
}

Ba vấn đề trong 5 dòng:

a) .then() rỗng — không kiểm tra thành công/thất bại, không thông báo gì cho người dùng. Bấm nút xong không có phản hồi, người dùng bấm lại nhiều lần.

b) try/catch không bắt được lỗi của Promise. fetch() trả về Promise ngay, lỗi xảy ra bất đồng bộ sau đó — catch đã chạy xong từ lâu. Muốn bắt phải dùng .catch() hoặc await bên trong try.

c) onSubmit khai báo async nhưng không có await nào.

Lỗi 2 — State formValue vô dụng

const [formValue, setformValue] = useState();
// ...
onSubmit: async (values) => {
  setformValue(values);      // ← gán rồi không dùng ở đâu cả

Còn gây tác dụng phụ: gọi setState làm component render lại giữa lúc submit.

Lỗi 3 — Input không có value (uncontrolled)

<input type="text" name="name" onChange={formik.handleChange} />
{/*     ↑ thiếu value={formik.values.name} */}

Hậu quả: formik.resetForm() không xóa được nội dung ô nhập. Cũng không thể điền sẵn giá trị (cần khi làm chức năng "Sửa sản phẩm" ở bài 20).

Lỗi 4 — <option selected> trong React

<option selected>Bạn chọn Danh Mục</option>

React cảnh báo:

Warning: Use the `defaultValue` or `value` props on <select> instead of setting `selected` on <option>

Ngoài ra option này không có value, nên nếu người dùng không chọn gì, categoryId sẽ là chuỗi text "Bạn chọn Danh Mục" → backend nhận và new ObjectId("Bạn chọn Danh Mục") ném lỗi.

Bản trên dùng <option value=""> — kết hợp required sẽ chặn được.

Lỗi 5 — Ảnh preview là placeholder không tồn tại

<img src="assets/img/products/iphone.webp" className="w-50" />

Thư mục assets/ không tồn tại trong dự án → icon ảnh vỡ. Và lại là ảnh iPhone trong website bán nước hoa.

Mục 6 sẽ làm preview thật.

Lỗi 6 — Ô "More Product Image" không hoạt động

<input className="form-control rounded-0" type="file" id="images" multiple />

Không có onChange, không gắn với Formik, backend cũng không nhận nhiều ảnh. Chọn file vào đây không có tác dụng gì. Bản trên đã xóa.

Lỗi 7 — Thuộc tính HTML sai trong JSX

<form action="" method="POST" enctype="multipart/form-data">
  • enctype → phải là encType trong JSX
  • action/method thừa — form được xử lý bằng JavaScript, không submit theo cách HTML

💻 6. Nâng cấp: validate với Yup + xem trước ảnh

Cài Yup:

npm install yup
"use client"
import { useFormik } from "formik";
import * as Yup from "yup";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import useSWR from "swr";
import { fetcher, API } from "@/lib/fetcher";

// ===== Quy tắc validate =====
const validationSchema = Yup.object({
  name: Yup.string()
    .trim()
    .min(3, "Tên phải có ít nhất 3 ký tự")
    .max(200, "Tên quá dài")
    .required("Vui lòng nhập tên sản phẩm"),
  description: Yup.string().max(5000, "Mô tả quá dài"),
  categoryId: Yup.string().required("Vui lòng chọn danh mục"),
  price: Yup.number()
    .typeError("Giá phải là số")
    .positive("Giá phải lớn hơn 0")
    .required("Vui lòng nhập giá"),
  image: Yup.mixed()
    .required("Vui lòng chọn ảnh")
    .test("fileSize", "Ảnh không được vượt quá 5MB",
      (file) => !file || file.size <= 5 * 1024 * 1024)
    .test("fileType", "Chỉ chấp nhận jpg, png, gif, webp",
      (file) => !file || ["image/jpeg", "image/png", "image/gif", "image/webp"].includes(file.type)),
});

export default function Productadd() {
  const { data: categorylist, error, isLoading } = useSWR(`${API}/categories`, fetcher);
  const router = useRouter();
  const [preview, setPreview] = useState(null);

  const formik = useFormik({
    initialValues: {
      name: "", description: "", categoryId: "", price: "", image: null,
    },
    validationSchema,
    onSubmit: async (values, { setStatus, resetForm }) => {
      const formData = new FormData();
      Object.entries(values).forEach(([key, value]) => formData.append(key, value));

      try {
        const res = await fetch(`${API}/products`, { method: "POST", body: formData });
        const result = await res.json();

        if (!res.ok) {
          setStatus(result.message || "Thêm sản phẩm thất bại");
          return;
        }

        resetForm();
        setPreview(null);
        router.push("/admin/product");
      } catch (err) {
        setStatus("Không kết nối được tới máy chủ");
      }
    },
  });

  // Component nhỏ hiển thị lỗi của một trường
  const FieldError = ({ name }) =>
    formik.touched[name] && formik.errors[name] ? (
      <div className="text-danger small mt-1">{formik.errors[name]}</div>
    ) : null;

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div className="alert alert-danger">Lỗi: {error.message}</div>;

  return (
    <>
      <div className="d-flex justify-content-between">
        <h3 className="mb-4">Thêm sản phẩm</h3>
        <Link href="/admin/product" className="btn btn-outline-secondary rounded-0">
          <i className="fas fa-arrow-left me-1"></i> Quay lại
        </Link>
      </div>

      {formik.status && <div className="alert alert-danger">{formik.status}</div>}

      <form className="row" onSubmit={formik.handleSubmit} noValidate>
        <div className="col-md-8 mb-4">
          <div className="card rounded-0 border-0 shadow-sm mb-4">
            <div className="card-body">
              <h6 className="pb-3 border-bottom">Thông tin cơ bản</h6>

              <div className="mb-3">
                <label htmlFor="name" className="form-label">Tên sản phẩm *</label>
                <input
                  type="text"
                  className={`form-control rounded-0 ${
                    formik.touched.name && formik.errors.name ? "is-invalid" : ""
                  }`}
                  id="name" name="name"
                  value={formik.values.name}
                  onChange={formik.handleChange}
                  onBlur={formik.handleBlur}
                />
                <FieldError name="name" />
              </div>

              <div className="mb-3">
                <label htmlFor="description" className="form-label">Mô tả</label>
                <textarea
                  className="form-control rounded-0"
                  id="description" name="description" rows="6"
                  value={formik.values.description}
                  onChange={formik.handleChange}
                  onBlur={formik.handleBlur}
                ></textarea>
                <FieldError name="description" />
              </div>

              <div className="mb-3">
                <label htmlFor="categoryId" className="form-label">Danh mục *</label>
                <select
                  className={`form-select rounded-0 ${
                    formik.touched.categoryId && formik.errors.categoryId ? "is-invalid" : ""
                  }`}
                  id="categoryId" name="categoryId"
                  value={formik.values.categoryId}
                  onChange={formik.handleChange}
                  onBlur={formik.handleBlur}
                >
                  <option value="">-- Chọn danh mục --</option>
                  {categorylist.map((item) => (
                    <option key={item._id} value={item._id}>{item.name}</option>
                  ))}
                </select>
                <FieldError name="categoryId" />
              </div>
            </div>
          </div>

          <div className="card rounded-0 border-0 shadow-sm">
            <div className="card-body">
              <h6 className="pb-3 border-bottom">Giá bán</h6>
              <div className="mb-3">
                <label htmlFor="price" className="form-label">Giá (VNĐ) *</label>
                <input
                  type="number"
                  className={`form-control rounded-0 ${
                    formik.touched.price && formik.errors.price ? "is-invalid" : ""
                  }`}
                  id="price" name="price" min="0" step="1000"
                  value={formik.values.price}
                  onChange={formik.handleChange}
                  onBlur={formik.handleBlur}
                />
                <FieldError name="price" />
                {formik.values.price > 0 && (
                  <small className="text-muted">
                    {Number(formik.values.price).toLocaleString("vi-VN")}đ
                  </small>
                )}
              </div>
            </div>
          </div>
        </div>

        <div className="col-md-4 mb-4">
          <div className="card rounded-0 border-0 shadow-sm">
            <div className="card-body">
              <h6 className="pb-3 border-bottom">Hình ảnh</h6>
              <div className="mb-3">
                <label htmlFor="image" className="form-label">Ảnh sản phẩm *</label>
                <input
                  className={`form-control rounded-0 ${
                    formik.touched.image && formik.errors.image ? "is-invalid" : ""
                  }`}
                  type="file" id="image" name="image" accept="image/*"
                  onBlur={formik.handleBlur}
                  onChange={(event) => {
                    const file = event.currentTarget.files[0];
                    formik.setFieldValue("image", file);
                    setPreview(file ? URL.createObjectURL(file) : null);
                  }}
                />
                <FieldError name="image" />

                {preview && (
                  <div className="bg-secondary-subtle mt-3 p-2 text-center">
                    <img src={preview} alt="Xem trước" className="w-75" />
                  </div>
                )}
              </div>
            </div>
          </div>

          <button
            type="submit"
            className="btn btn-primary btn-lg rounded-0 mt-4 w-100"
            disabled={formik.isSubmitting}
          >
            {formik.isSubmitting ? "Đang lưu..." : "Tạo sản phẩm"}
          </button>
        </div>
      </form>
    </>
  );
}

🔍 Kỹ thuật mới

URL.createObjectURL(file) — tạo URL tạm trỏ tới file trong bộ nhớ trình duyệt, dùng làm src cho <img> mà không cần upload lên server.

💡 Nên gọi URL.revokeObjectURL(preview) khi không dùng nữa để giải phóng bộ nhớ. Với form nhỏ thì không bắt buộc.

formik.status — nơi lưu thông báo lỗi chung của cả form (khác errors là lỗi từng trường). Đặt bằng setStatus(...) trong onSubmit.

is-invalid của Bootstrap — thêm class này vào input sẽ hiện viền đỏ.

noValidate trên <form> — tắt validate mặc định của trình duyệt để Yup toàn quyền kiểm soát. Nếu không, trình duyệt sẽ hiện popup tiếng Anh chồng lên thông báo tiếng Việt của bạn.

Object.entries(values).forEach(...) — gọn hơn viết 5 dòng formData.append.


✅ 7. Kiểm thử

Mở http://localhost:3000/admin/product/add:

Test validate

Thao tác Kết quả mong đợi
Bấm "Tạo sản phẩm" khi form rỗng Tất cả trường bắt buộc hiện lỗi đỏ, không gửi request
Nhập tên 2 ký tự rồi rời ô "Tên phải có ít nhất 3 ký tự"
Nhập giá -100 "Giá phải lớn hơn 0"
Chọn file .txt "Chỉ chấp nhận jpg, png, gif, webp"
Chọn ảnh > 5MB "Ảnh không được vượt quá 5MB"

Test luồng thành công

  1. Nhập tên: Dior Sauvage Elixir
  2. Mô tả: Hương đầu: Bạch đậu khấu, Quế, Nhục đậu khấu
  3. Chọn danh mục bất kỳ
  4. Giá: 4200000 → thấy hiện 4.200.000đ bên dưới
  5. Chọn ảnh → ảnh hiện ra ngay ở khung preview
  6. Bấm "Tạo sản phẩm"
  7. ✅ Chuyển về /admin/product, sản phẩm mới có trong bảng
  8. ✅ Ảnh hiển thị đúng trong bảng
  9. ✅ Vào /menu bên khu khách hàng cũng thấy sản phẩm mới

Kiểm tra 3 nơi

  1. Ổ đĩa: backend/public/img/ có file ảnh mới
  2. Database: Compass → products có document mới, price là số không phải chuỗi
  3. Web: cả /admin/product và /menu đều hiện sản phẩm

❗ Nếu backend vẫn dùng _id: null (bài 07 mục 4), sản phẩm thứ hai sẽ lỗi E11000 duplicate key. Đảm bảo đã xóa dòng đó.


🆘 Xử lý sự cố

<details> <summary>req.body rỗng ở backend</summary>

Nguyên nhân thường gặp nhất: bạn đặt header Content-Type thủ công.

// ❌ Bỏ dòng headers đi
fetch(url, { method: "POST", body: formData })

Kiểm tra trong DevTools → Network → request /products → Headers. Content-Type phải là multipart/form-data; boundary=----WebKitFormBoundary...

</details> <details> <summary>LIMIT_UNEXPECTED_FILE</summary>

Tên trường không khớp:

formData.append("image", file);    // frontend
upload.single("image")             // backend — phải cùng tên
</details> <details> <summary>Bấm submit nhưng không có gì xảy ra</summary>

Formik đang chặn vì validate thất bại. Debug bằng cách thêm tạm:

<pre>{JSON.stringify(formik.errors, null, 2)}</pre>

để xem trường nào đang lỗi.

Cũng kiểm tra <button type="submit"> — nếu thiếu type hoặc để type="button" thì form không submit.

</details> <details> <summary>Giá lưu vào DB là chuỗi "4200000"</summary>

Backend chưa ép kiểu. Sửa routes/products.js:

price: Number(req.body.price),
</details>

📚 8. Formik hay React Hook Form?

Formik ra đời sớm và rất phổ biến, nhưng hiện nay React Hook Form được ưa chuộng hơn:

Formik React Hook Form
Số lần re-render Mỗi ký tự gõ → render lại cả form Chỉ render trường thay đổi
Kích thước ~13 KB ~9 KB
Cú pháp useFormik + handleChange register()
Bảo trì Ít cập nhật gần đây Rất tích cực

Dự án dùng Formik, và tài liệu này bám theo. Nhưng nếu bạn bắt đầu dự án mới, hãy cân nhắc React Hook Form.


📝 Bài tập

  1. Upload nhiều ảnh: dùng upload.array("images", 5) ở backend, formData.append("images", file) cho từng file ở frontend, hiển thị preview dạng lưới.

  2. Kéo thả ảnh (drag & drop): dùng sự kiện onDragOver và onDrop để nhận file thay vì phải bấm chọn.

  3. Nén ảnh ở client trước khi upload bằng thư viện browser-image-compression:

    npm install browser-image-compression
    
  4. Thanh tiến trình upload: fetch không hỗ trợ theo dõi tiến trình. Dùng XMLHttpRequest với sự kiện upload.onprogress.

  5. Form thêm danh mục ở /admin/category/add — tương tự nhưng đơn giản hơn (chỉ name và image). Nhớ viết API POST /categories ở backend.

  6. Tự tạo lỗi để hiểu: thêm headers: {"Content-Type": "multipart/form-data"} vào fetch, rồi submit form. Xem req.body và req.file ở backend log ra gì. Sau đó bỏ đi và so sánh.


⬅️ Bài trước | Mục lục | Bài tiếp theo: Đăng nhập & Đăng ký với JWT ➡️


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í