לדלג לתוכן

7.9 טפסים React Hook Form ו Zod הרצאה

טפסים - React Hook Form ו-Zod

בשיעור הזה נלמד על React Hook Form - ספריית ניהול טפסים עם ביצועים גבוהים, ועל Zod - ספריית ולידציה עם TypeScript. נלמד איך לשלב ביניהן לטפסים מורכבים עם ולידציה חזקה.


למה ספריית טפסים?

בעיות עם טפסים רגילים בריאקט

// הגישה הנאיבית
function ContactForm() {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [message, setMessage] = useState("");
  const [errors, setErrors] = useState({});

  // כל שינוי בשדה גורם לרנדר של כל הטופס!
  // ולידציה ידנית - הרבה קוד שחוזר על עצמו
}

בעיות:
- כל שינוי בשדה גורם לרנדר מחדש של כל הטופס (בעיית ביצועים)
- ולידציה ידנית דורשת הרבה קוד
- ניהול state של שדות רבים מסורבל
- ניהול שגיאות ו-touched state מורכב


התקנה

npm install react-hook-form zod @hookform/resolvers

React Hook Form - בסיס

שימוש בסיסי

import { useForm } from "react-hook-form";

interface FormData {
  name: string;
  email: string;
  message: string;
}

function ContactForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormData>();

  const onSubmit = async (data: FormData) => {
    console.log("נתונים:", data);
    await new Promise((r) => setTimeout(r, 1000)); // סימולציה
    alert("הטופס נשלח!");
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label>שם</label>
        <input
          {...register("name", {
            required: "שם הוא שדה חובה",
            minLength: { value: 2, message: "לפחות 2 תווים" },
          })}
        />
        {errors.name && <p style={{ color: "red" }}>{errors.name.message}</p>}
      </div>

      <div>
        <label>אימייל</label>
        <input
          type="email"
          {...register("email", {
            required: "אימייל הוא שדה חובה",
            pattern: {
              value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
              message: "אימייל לא תקין",
            },
          })}
        />
        {errors.email && <p style={{ color: "red" }}>{errors.email.message}</p>}
      </div>

      <div>
        <label>הודעה</label>
        <textarea
          {...register("message", {
            required: "הודעה היא שדה חובה",
            maxLength: { value: 500, message: "מקסימום 500 תווים" },
          })}
        />
        {errors.message && <p style={{ color: "red" }}>{errors.message.message}</p>}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "שולח..." : "שלח"}
      </button>
    </form>
  );
}
  • register מחבר שדה input לטופס (מחזיר ref, onChange, onBlur, name)
  • handleSubmit עוטף את פונקציית ה-submit ומריץ ולידציה לפני
  • errors מכיל שגיאות ולידציה לכל שדה
  • הטופס לא גורם לרנדר מחדש בכל שינוי שדה - רק כש-submit או כשיש שגיאה

אפשרויות useForm

const form = useForm<FormData>({
  defaultValues: {
    name: "",
    email: "",
    message: "",
  },
  mode: "onBlur", // מתי לבדוק ולידציה: onChange, onBlur, onSubmit, onTouched, all
});

ולידציה עם Zod

מה זה Zod?

Zod היא ספריית ולידציה ל-TypeScript שמאפשרת להגדיר סכמות (schemas) ולבצע ולידציה עם טיפוסים אוטומטיים:

import { z } from "zod";

// הגדרת סכמה
const userSchema = z.object({
  name: z
    .string()
    .min(2, "שם חייב להכיל לפחות 2 תווים")
    .max(50, "שם ארוך מדי"),
  email: z
    .string()
    .email("כתובת אימייל לא תקינה"),
  age: z
    .number()
    .min(18, "גיל מינימלי 18")
    .max(120, "גיל לא תקין"),
  website: z
    .string()
    .url("כתובת URL לא תקינה")
    .optional(),
});

// הטיפוס נגזר אוטומטית מהסכמה
type User = z.infer<typeof userSchema>;
// User = { name: string; email: string; age: number; website?: string }

סוגי ולידציה ב-Zod

// מחרוזות
z.string().min(1).max(100).email().url().startsWith("https://")

// מספרים
z.number().int().positive().min(0).max(100)

// בוליאני
z.boolean()

// תאריכים
z.date().min(new Date("2020-01-01"))

// Enum
z.enum(["admin", "user", "guest"])

// מערך
z.array(z.string()).min(1).max(10)

// אובייקט מקונן
z.object({
  address: z.object({
    street: z.string(),
    city: z.string(),
  }),
})

// Union
z.union([z.string(), z.number()])

// ולידציה מותאמת
z.string().refine((val) => val.includes("@"), "חייב לכלול @")

// טרנספורמציה
z.string().transform((val) => val.trim().toLowerCase())

שילוב React Hook Form עם Zod

zodResolver

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const registrationSchema = z
  .object({
    name: z.string().min(2, "שם חייב להכיל לפחות 2 תווים"),
    email: z.string().email("אימייל לא תקין"),
    password: z
      .string()
      .min(8, "סיסמה חייבת להכיל לפחות 8 תווים")
      .regex(/[A-Z]/, "חייבת לכלול אות גדולה")
      .regex(/[0-9]/, "חייבת לכלול ספרה"),
    confirmPassword: z.string(),
    terms: z.boolean().refine((val) => val, "יש לאשר את התנאים"),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "הסיסמאות לא תואמות",
    path: ["confirmPassword"],
  });

type RegistrationData = z.infer<typeof registrationSchema>;

function RegistrationForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<RegistrationData>({
    resolver: zodResolver(registrationSchema),
    defaultValues: {
      name: "",
      email: "",
      password: "",
      confirmPassword: "",
      terms: false,
    },
  });

  const onSubmit = async (data: RegistrationData) => {
    console.log("נתונים תקינים:", data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label>שם</label>
        <input {...register("name")} />
        {errors.name && <p style={{ color: "red" }}>{errors.name.message}</p>}
      </div>

      <div>
        <label>אימייל</label>
        <input type="email" {...register("email")} />
        {errors.email && <p style={{ color: "red" }}>{errors.email.message}</p>}
      </div>

      <div>
        <label>סיסמה</label>
        <input type="password" {...register("password")} />
        {errors.password && (
          <p style={{ color: "red" }}>{errors.password.message}</p>
        )}
      </div>

      <div>
        <label>אישור סיסמה</label>
        <input type="password" {...register("confirmPassword")} />
        {errors.confirmPassword && (
          <p style={{ color: "red" }}>{errors.confirmPassword.message}</p>
        )}
      </div>

      <div>
        <label>
          <input type="checkbox" {...register("terms")} />
          אני מאשר את התנאים
        </label>
        {errors.terms && <p style={{ color: "red" }}>{errors.terms.message}</p>}
      </div>

      <button type="submit" disabled={isSubmitting}>
        הירשם
      </button>
    </form>
  );
}
  • zodResolver מחבר את סכמת Zod ל-React Hook Form
  • הטיפוס נגזר אוטומטית מהסכמה - אין צורך להגדיר interface נפרד
  • refine ברמת הסכמה מאפשר ולידציה שתלויה במספר שדות (כמו אישור סיסמה)

טפסים מורכבים - מערכים

import { useForm, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const orderSchema = z.object({
  customerName: z.string().min(2),
  items: z
    .array(
      z.object({
        name: z.string().min(1, "שם מוצר נדרש"),
        quantity: z.number().min(1, "כמות מינימלית 1"),
        price: z.number().min(0, "מחיר לא תקין"),
      })
    )
    .min(1, "יש להוסיף לפחות מוצר אחד"),
});

type OrderData = z.infer<typeof orderSchema>;

function OrderForm() {
  const {
    register,
    control,
    handleSubmit,
    formState: { errors },
  } = useForm<OrderData>({
    resolver: zodResolver(orderSchema),
    defaultValues: {
      customerName: "",
      items: [{ name: "", quantity: 1, price: 0 }],
    },
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: "items",
  });

  const onSubmit = (data: OrderData) => {
    console.log("הזמנה:", data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label>שם לקוח</label>
        <input {...register("customerName")} />
        {errors.customerName && (
          <p style={{ color: "red" }}>{errors.customerName.message}</p>
        )}
      </div>

      <h3>מוצרים</h3>
      {fields.map((field, index) => (
        <div key={field.id} style={{ border: "1px solid #ddd", padding: "12px", margin: "8px 0" }}>
          <input
            {...register(`items.${index}.name`)}
            placeholder="שם מוצר"
          />
          {errors.items?.[index]?.name && (
            <p style={{ color: "red" }}>{errors.items[index]?.name?.message}</p>
          )}

          <input
            type="number"
            {...register(`items.${index}.quantity`, { valueAsNumber: true })}
            placeholder="כמות"
          />

          <input
            type="number"
            {...register(`items.${index}.price`, { valueAsNumber: true })}
            placeholder="מחיר"
          />

          <button type="button" onClick={() => remove(index)}>
            הסר
          </button>
        </div>
      ))}

      {errors.items?.root && (
        <p style={{ color: "red" }}>{errors.items.root.message}</p>
      )}

      <button type="button" onClick={() => append({ name: "", quantity: 1, price: 0 })}>
        הוסף מוצר
      </button>

      <button type="submit">שלח הזמנה</button>
    </form>
  );
}
  • useFieldArray מנהל מערך דינמי של שדות
  • append, remove, insert, move - פעולות על המערך
  • valueAsNumber ממיר את הערך למספר אוטומטית

אובייקטים מקוננים

const addressSchema = z.object({
  personal: z.object({
    firstName: z.string().min(1, "שם פרטי נדרש"),
    lastName: z.string().min(1, "שם משפחה נדרש"),
  }),
  address: z.object({
    street: z.string().min(1, "רחוב נדרש"),
    city: z.string().min(1, "עיר נדרשת"),
    zip: z.string().regex(/^\d{7}$/, "מיקוד חייב להכיל 7 ספרות"),
  }),
});

type AddressData = z.infer<typeof addressSchema>;

function AddressForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<AddressData>({
    resolver: zodResolver(addressSchema),
  });

  return (
    <form onSubmit={handleSubmit(console.log)}>
      <input {...register("personal.firstName")} placeholder="שם פרטי" />
      {errors.personal?.firstName && (
        <p style={{ color: "red" }}>{errors.personal.firstName.message}</p>
      )}

      <input {...register("personal.lastName")} placeholder="שם משפחה" />

      <input {...register("address.street")} placeholder="רחוב" />
      <input {...register("address.city")} placeholder="עיר" />
      <input {...register("address.zip")} placeholder="מיקוד" />
      {errors.address?.zip && (
        <p style={{ color: "red" }}>{errors.address.zip.message}</p>
      )}

      <button type="submit">שלח</button>
    </form>
  );
}

הודעות שגיאה ו-UX

קומפוננטת שגיאה

interface FormFieldProps {
  label: string;
  error?: string;
  children: React.ReactNode;
}

function FormField({ label, error, children }: FormFieldProps) {
  return (
    <div style={{ marginBottom: "16px" }}>
      <label style={{ display: "block", marginBottom: "4px", fontWeight: "bold" }}>
        {label}
      </label>
      {children}
      {error && (
        <p
          style={{
            color: "#dc3545",
            fontSize: "13px",
            marginTop: "4px",
          }}
        >
          {error}
        </p>
      )}
    </div>
  );
}

// שימוש
function MyForm() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(schema),
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <FormField label="שם" error={errors.name?.message}>
        <input {...register("name")} />
      </FormField>

      <FormField label="אימייל" error={errors.email?.message}>
        <input type="email" {...register("email")} />
      </FormField>
    </form>
  );
}

Watch - מעקב אחרי ערכים

function DynamicForm() {
  const { register, watch } = useForm();
  const accountType = watch("accountType");

  return (
    <form>
      <select {...register("accountType")}>
        <option value="personal">אישי</option>
        <option value="business">עסקי</option>
      </select>

      {accountType === "business" && (
        <div>
          <input {...register("companyName")} placeholder="שם חברה" />
          <input {...register("taxId")} placeholder="מספר עוסק" />
        </div>
      )}
    </form>
  );
}

Reset

function ResettableForm() {
  const { register, handleSubmit, reset } = useForm({
    defaultValues: { name: "", email: "" },
  });

  const onSubmit = (data: any) => {
    console.log(data);
    reset(); // חזרה לערכי ברירת מחדל
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("name")} />
      <input {...register("email")} />
      <button type="submit">שלח</button>
      <button type="button" onClick={() => reset()}>
        נקה
      </button>
    </form>
  );
}

סיכום

  • React Hook Form מנהלת טפסים עם ביצועים גבוהים - לא גורמת לרנדר מחדש בכל שינוי שדה
  • register מחבר שדה input לטופס, handleSubmit מטפל בשליחה עם ולידציה
  • Zod מאפשרת להגדיר סכמות ולידציה עם טיפוסי TypeScript אוטומטיים
  • zodResolver מחבר בין Zod ל-React Hook Form
  • useFieldArray מטפל במערכים דינמיים של שדות
  • גישה מקוננת (nested objects) עובדת עם dot notation (למשל "address.city")
  • watch מאפשר לעקוב אחרי ערכי שדות ולהציג תוכן דינמי
  • reset מאפס את הטופס לערכי ברירת מחדל