Mẫu Form Tạo và Chỉnh Sửa Tái Sử Dụng với React Hook Form và Zod
Forms trong React từ lâu đã là nỗi ám ảnh đối với nhiều lập trình viên, đặc biệt là khi cần thiết lập validation và giá trị mặc định cho các form. Trong bài viết này, chúng ta sẽ tìm hiểu cách tối ưu hóa việc quản lý form trong React bằng cách kết hợp React Hook Form và Zod để tạo ra các form có thể tái sử dụng cho cả việc tạo và chỉnh sửa mà không cần phải viết lại mã.
Giới thiệu
Khi bắt đầu một dự án mới, việc thiết lập form luôn là một thách thức lớn. Mỗi lần cần cập nhật form, tôi lại phải kiểm tra qua nhiều tệp để đảm bảo mọi thứ vẫn hoạt động. Điều này trở nên tồi tệ hơn khi tôi bắt đầu xây dựng các form phức tạp cho các hệ thống điểm số đa nền tảng như Instagram và YouTube. Giải pháp đơn giản là gộp lại schema và giá trị mặc định trong một hook tùy chỉnh để xử lý cả form tạo và chỉnh sửa mà không gây ra sự dư thừa.
Tóm lại: Hãy kết hợp schema Zod và giá trị mặc định trong một hook tùy chỉnh để quản lý cả form tạo và chỉnh sửa một cách hiệu quả.
Vấn Đề: Đau Đầu Vì Sự Trùng Lặp Form
Nếu bạn đã làm việc với các form trong React, bạn có thể đã gặp phải tình huống này:
- Bạn tạo một form với validation.
- Sau đó, bạn cần một phiên bản "chỉnh sửa" của cùng một form.
- Bạn sao chép hầu hết mã của mình nhưng cần xử lý các giá trị mặc định khác nhau.
- Một yêu cầu thay đổi và bạn phải cập nhật cả hai form.
- Bạn quên cập nhật một trong hai form và lỗi xảy ra.
- Lặp lại cho đến khi bạn tự hỏi về sự nghiệp của mình.
Tôi đã mệt mỏi với việc duy trì các cấu hình form gần như giống hệt nhau giữa các thành phần khác nhau. Nhất là khi sử dụng các thư viện như React Hook Form và Zod, cảm giác như tôi đang lặp lại nỗ lực mà không có lý do chính đáng.
Giải Pháp: Hook Tùy Chỉnh Schema
Dưới đây là phiên bản đơn giản hóa của giải pháp mà tôi đã phát triển:
javascript
import { useMemo } from "react";
import { z } from "zod";
export const useProfileSchema = ({ data = {} } = {}) => {
return useMemo(() => {
// Định nghĩa schema validation một lần
const schema = z.object({
username: z.string().min(1, { message: "Tên người dùng là bắt buộc" }),
age: z.number().min(18, { message: "Phải ít nhất 18 tuổi" }),
subscribe: z.boolean().default(false),
});
// Tạo giá trị mặc định dựa trên dữ liệu có sẵn hoặc giá trị trống
const defaults = {
username: data?.username || "",
age: data?.age || 18,
subscribe: data?.subscribe || false,
};
return { schema, defaults };
}, [data]);
};
Điều thú vị nằm ở tham số data. Khi tạo một form mới, bạn không truyền gì cả. Khi chỉnh sửa một bản ghi hiện tại, bạn truyền vào các giá trị hiện tại. Hook sẽ xử lý mọi thứ!
Thực Thi Giải Pháp
Khi tôi đã nắm vững mô hình này, việc thiết lập form trở nên dễ dàng hơn rất nhiều. Tôi chỉ cần gọi hook này, kết nối schema của nó với React Hook Form thông qua resolver, và sử dụng các giá trị mặc định để khởi tạo form:
javascript
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useProfileSchema } from "./useProfileSchema";
const ProfileForm = ({ initialData = {}, onSubmit }) => {
const { schema, defaults } = useProfileSchema({ data: initialData });
const form = useForm({
resolver: zodResolver(schema),
defaultValues: defaults,
});
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="form-group">
<label htmlFor="username">Tên người dùng</label>
<input
id="username"
{...form.register("username")}
placeholder="Nhập tên người dùng"
className="form-control"
/>
{form.formState.errors.username && (
<span className="error">{form.formState.errors.username.message}</span>
)}
</div>
<div className="form-group">
<label htmlFor="age">Tuổi</label>
<input
id="age"
type="number"
{...form.register("age", { valueAsNumber: true })}
placeholder="Nhập tuổi"
className="form-control"
/>
{form.formState.errors.age && (
<span className="error">{form.formState.errors.age.message}</span>
)}
</div>
<div className="form-check">
<input
id="subscribe"
type="checkbox"
{...form.register("subscribe")}
className="form-check-input"
/>
<label htmlFor="subscribe" className="form-check-label">
Đăng ký nhận bản tin
</label>
</div>
<button type="submit" className="btn btn-primary mt-3">
{initialData.id ? "Cập nhật hồ sơ" : "Tạo hồ sơ"}
</button>
</form>
);
};
Điều đẹp đẽ ở đây là: Thành phần này có thể hoạt động cho cả việc tạo và chỉnh sửa hồ sơ. Bạn chỉ cần thay đổi việc truyền vào initialData hoặc không:
javascript
// Tạo hồ sơ mới
<ProfileForm onSubmit={handleCreate} />
// Chỉnh sửa hồ sơ hiện tại
<ProfileForm initialData={existingProfile} onSubmit={handleUpdate} />
Lợi Ích Thực Tế
Sau khi áp dụng mô hình này trên nhiều dự án, tôi đã thu được các lợi ích sau:
- Nguồn duy nhất của sự thật: Các quy tắc validation và giá trị mặc định sống cùng nhau, do đó không bao giờ bị mất đồng bộ.
- Mã DRY: Không còn việc sao chép và dán logic form giữa các trang tạo và chỉnh sửa.
- Duy trì dễ dàng hơn: Cần thay đổi một quy tắc validation? Thực hiện một lần trong hook, không cần ở nhiều nơi.
- Tổ chức mã tốt hơn: Tất cả logic liên quan đến form đều có một vị trí rõ ràng.
- Chuyển giao dễ dàng hơn: Các thành viên mới trong nhóm có thể nhanh chóng hiểu cách thức hoạt động của các form trong mã nguồn của chúng tôi.
Đi Xa Hơn: Các Mô Hình Nâng Cao
Đối với các dự án lớn hơn, tôi đã mở rộng mô hình này theo nhiều cách đã chứng minh là vô giá:
1. Thêm TypeScript để Tăng Cường An Toàn
Thêm các định nghĩa TypeScript làm cho mô hình này mạnh mẽ hơn:
javascript
// Định nghĩa kiểu dữ liệu cho schema
type ProfileFormData = {
username: string;
age: number;
subscribe: boolean;
};
export const useProfileSchema = ({ data = {} as Partial<ProfileFormData> } = {}) => {
return useMemo(() => {
const schema = z.object({
username: z.string().min(1, { message: "Tên người dùng là bắt buộc" }),
age: z.number().min(18, { message: "Phải ít nhất 18 tuổi" }),
subscribe: z.boolean().default(false),
});
const defaults: ProfileFormData = {
username: data?.username || "",
age: data?.age || 18,
subscribe: data?.subscribe || false,
};
return { schema, defaults };
}, [data]);
};
2. Thêm Hàm Chuyển Đổi
Đôi khi dữ liệu từ API không hoàn toàn phù hợp với những gì form của bạn cần. Việc thêm các hàm chuyển đổi vào hook của bạn có thể giải quyết vấn đề này:
javascript
export const useProfileSchema = ({ data = {} } = {}) => {
return useMemo(() => {
// Định nghĩa schema...
// Chuyển đổi dữ liệu API sang định dạng form
const apiToForm = (apiData) => ({
username: apiData.user_name,
age: apiData.user_age,
subscribe: Boolean(apiData.newsletter_opt_in),
});
// Chuyển đổi dữ liệu form trở lại định dạng API
const formToApi = (formData) => ({
user_name: formData.username,
user_age: formData.age,
newsletter_opt_in: formData.subscribe ? 1 : 0,
});
const defaults = data ? apiToForm(data) : {
username: "",
age: 18,
subscribe: false,
};
return { schema, defaults, apiToForm, formToApi };
}, [data]);
};
3. Hỗ Trợ Validation Điều Kiện
Đối với các form phức tạp hơn mà quy tắc validation thay đổi dựa trên các trường khác:
javascript
export const usePaymentSchema = ({ data = {} } = {}) => {
return useMemo(() => {
const schema = z.object({
paymentMethod: z.enum(["credit", "bank"]),
creditCardNumber: z.string().optional(),
bankAccountNumber: z.string().optional(),
}).refine((data) => {
if (data.paymentMethod === "credit" && !data.creditCardNumber) {
return false;
}
if (data.paymentMethod === "bank" && !data.bankAccountNumber) {
return false;
}
return true;
}, {
message: "Vui lòng điền các thông tin thanh toán cần thiết",
path: ["paymentMethod"],
});
// Phần còn lại của hook...
}, [data]);
};
4. Đặt Lại Form và Giá Trị Khởi Tạo
Một thách thức mà tôi gặp phải là xử lý việc đặt lại form một cách chính xác. Dưới đây là một mô hình hoạt động tốt:
javascript
const ProfileForm = ({ initialData = {}, onSubmit }) => {
const { schema, defaults } = useProfileSchema({ data: initialData });
const form = useForm({
resolver: zodResolver(schema),
defaultValues: defaults,
});
// Đặt lại form khi initialData thay đổi (hữu ích trong các tình huống chỉnh sửa)
useEffect(() => {
form.reset(defaults);
}, [initialData, form, defaults]);
const handleReset = () => {
form.reset(defaults);
};
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* Các trường form... */}
<div className="button-group">
<button type="submit" className="btn btn-primary">
{initialData.id ? "Cập nhật" : "Tạo"}
</button>
<button type="button" onClick={handleReset} className="btn btn-secondary">
Đặt lại
</button>
</div>
</form>
);
};
Những Cân Nhắc Về Hiệu Suất
Đối với các form lớn hơn, bạn có thể muốn tối ưu hóa hook của mình hơn nữa:
javascript
export const useProfileSchema = ({ data = {} } = {}) => {
// Ghi nhớ dữ liệu để tránh tính toán không cần thiết
const memoizedData = useMemo(() => data, [
// Chỉ bao gồm các khóa mà bạn quan tâm
data.username,
data.age,
data.subscribe
]);
return useMemo(() => {
// Logic schema và giá trị mặc định
return { schema, defaults };
}, [memoizedData]); // Sử dụng dữ liệu đã ghi nhớ
};
Kết Luận
Thực sự, việc thay đổi nhỏ này đã làm cho quy trình làm việc của tôi trở nên mượt mà hơn rất nhiều. Nếu bạn đã từng cảm thấy bực bội khi phải quản lý trạng thái và validation của form qua nhiều tệp khác nhau, hãy thử áp dụng mô hình này. Nó đã giúp tôi tiết kiệm rất nhiều thời gian và căng thẳng, và tôi tin rằng nó cũng sẽ giúp bạn.
Điều tuyệt vời nhất? Mô hình này có thể hoạt động với bất kỳ thư viện form nào trong React - không chỉ riêng React Hook Form. Nguyên tắc kết hợp schema và giá trị mặc định trong một hook tùy chỉnh là có thể áp dụng rộng rãi.
Bạn đã tìm thấy những mô hình form nào hữu ích trong các dự án React của mình? Hãy cho tôi biết trong phần bình luận!
Theo dõi tôi để nhận thêm nhiều mẹo và mẫu React giúp việc phát triển trở nên dễ dàng và thú vị hơn. Nếu bài viết này hữu ích với bạn, hãy xem xét chia sẻ với nhóm của bạn!