# 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:
-
KHÔNG đặt header
Content-Typethủ công// ❌ SAI fetch(url, { method: "POST", headers: { "Content-Type": "multipart/form-data" }, body: formData });multipart/form-datacần kèm chuỗiboundaryngẫ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.bodyrỗng. -
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. -
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àencTypetrong JSXaction/methodthừ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
- Nhập tên:
Dior Sauvage Elixir - Mô tả:
Hương đầu: Bạch đậu khấu, Quế, Nhục đậu khấu - Chọn danh mục bất kỳ
- Giá:
4200000→ thấy hiện4.200.000đbên dưới - Chọn ảnh → ảnh hiện ra ngay ở khung preview
- Bấm "Tạo sản phẩm"
- ✅ Chuyển về
/admin/product, sản phẩm mới có trong bảng - ✅ Ảnh hiển thị đúng trong bảng
- ✅ Vào
/menubên khu khách hàng cũng thấy sản phẩm mới
Kiểm tra 3 nơi
- Ổ đĩa:
backend/public/img/có file ảnh mới - Database: Compass →
productscó document mới,pricelà số không phải chuỗi - Web: cả
/admin/productvà/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ỗiE11000 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...
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.
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
-
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. -
Kéo thả ảnh (drag & drop): dùng sự kiện
onDragOvervàonDropđể nhận file thay vì phải bấm chọn. -
Nén ảnh ở client trước khi upload bằng thư viện
browser-image-compression:npm install browser-image-compression -
Thanh tiến trình upload:
fetchkhông hỗ trợ theo dõi tiến trình. DùngXMLHttpRequestvới sự kiệnupload.onprogress. -
Form thêm danh mục ở
/admin/category/add— tương tự nhưng đơn giản hơn (chỉnamevàimage). Nhớ viết APIPOST /categoriesở backend. -
Tự tạo lỗi để hiểu: thêm
headers: {"Content-Type": "multipart/form-data"}vàofetch, rồi submit form. Xemreq.bodyvà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