לדלג לתוכן

7.9 טפסים React Hook Form ו Zod פתרון

פתרון - טפסים - React Hook Form ו-Zod


פתרון תרגיל 1 - טופס הרשמה בסיסי

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

const registrationSchema = z
  .object({
    firstName: z.string().min(2, "שם פרטי חייב להכיל לפחות 2 תווים"),
    lastName: z.string().min(2, "שם משפחה חייב להכיל לפחות 2 תווים"),
    email: z.string().email("כתובת אימייל לא תקינה"),
    password: z
      .string()
      .min(8, "סיסמה חייבת להכיל לפחות 8 תווים")
      .regex(/[A-Z]/, "חייבת לכלול לפחות אות גדולה אחת")
      .regex(/[0-9]/, "חייבת לכלול לפחות ספרה אחת")
      .regex(/[^A-Za-z0-9]/, "חייבת לכלול לפחות תו מיוחד אחד"),
    confirmPassword: z.string(),
    birthDate: z.string().refine(
      (date) => {
        const age = (Date.now() - new Date(date).getTime()) / (365.25 * 24 * 60 * 60 * 1000);
        return age >= 16;
      },
      "גיל מינימלי להרשמה הוא 16"
    ),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "הסיסמאות לא תואמות",
    path: ["confirmPassword"],
  });

type RegistrationData = z.infer<typeof registrationSchema>;

function RegistrationForm() {
  const [success, setSuccess] = useState(false);

  const {
    register,
    handleSubmit,
    reset,
    formState: { errors, isSubmitting },
  } = useForm<RegistrationData>({
    resolver: zodResolver(registrationSchema),
    defaultValues: {
      firstName: "",
      lastName: "",
      email: "",
      password: "",
      confirmPassword: "",
      birthDate: "",
    },
  });

  const onSubmit = async (data: RegistrationData) => {
    await new Promise((r) => setTimeout(r, 1500));
    console.log("נרשם:", data);
    setSuccess(true);
    reset();
    setTimeout(() => setSuccess(false), 3000);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <h2>הרשמה</h2>

      {success && (
        <div style={{ padding: "12px", backgroundColor: "#d4edda", borderRadius: "4px" }}>
          ההרשמה בוצעה בהצלחה!
        </div>
      )}

      <div>
        <label>שם פרטי</label>
        <input {...register("firstName")} />
        {errors.firstName && <p style={{ color: "red" }}>{errors.firstName.message}</p>}
      </div>

      <div>
        <label>שם משפחה</label>
        <input {...register("lastName")} />
        {errors.lastName && <p style={{ color: "red" }}>{errors.lastName.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>תאריך לידה</label>
        <input type="date" {...register("birthDate")} />
        {errors.birthDate && <p style={{ color: "red" }}>{errors.birthDate.message}</p>}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "נרשם..." : "הירשם"}
      </button>
    </form>
  );
}

הסבר:
- הסכמה כוללת ולידציות ברמת שדה (min, regex) וברמת טופס (refine להשוואת סיסמאות)
- תאריך הלידה נבדק עם refine שמחשב גיל ומוודא מינימום 16
- אחרי שליחה מוצלחת, הטופס מתאפס עם reset והודעת הצלחה מוצגת


פתרון תרגיל 2 - טופס כתובת עם אובייקטים מקוננים

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

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

const orderSchema = z.object({
  personal: z.object({
    firstName: z.string().min(1, "שם פרטי נדרש"),
    lastName: z.string().min(1, "שם משפחה נדרש"),
    phone: z.string().regex(/^05\d{8}$/, "מספר טלפון לא תקין (05XXXXXXXX)"),
  }),
  shippingAddress: addressSchema,
  sameAsBilling: z.boolean(),
  billingAddress: addressSchema.optional(),
});

type OrderData = z.infer<typeof orderSchema>;

function OrderForm() {
  const {
    register,
    handleSubmit,
    watch,
    setValue,
    formState: { errors },
  } = useForm<OrderData>({
    resolver: zodResolver(orderSchema),
    defaultValues: {
      personal: { firstName: "", lastName: "", phone: "" },
      shippingAddress: { street: "", city: "", zip: "", floor: "" },
      sameAsBilling: true,
      billingAddress: { street: "", city: "", zip: "", floor: "" },
    },
  });

  const sameAsBilling = watch("sameAsBilling");
  const shippingAddress = watch("shippingAddress");

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

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

      <input {...register("personal.lastName")} placeholder="שם משפחה" />
      {errors.personal?.lastName && (
        <p style={{ color: "red" }}>{errors.personal.lastName.message}</p>
      )}

      <input {...register("personal.phone")} placeholder="טלפון (05XXXXXXXX)" />
      {errors.personal?.phone && (
        <p style={{ color: "red" }}>{errors.personal.phone.message}</p>
      )}

      <h2>כתובת משלוח</h2>
      <input {...register("shippingAddress.street")} placeholder="רחוב" />
      {errors.shippingAddress?.street && (
        <p style={{ color: "red" }}>{errors.shippingAddress.street.message}</p>
      )}
      <input {...register("shippingAddress.city")} placeholder="עיר" />
      <input {...register("shippingAddress.zip")} placeholder="מיקוד" />
      {errors.shippingAddress?.zip && (
        <p style={{ color: "red" }}>{errors.shippingAddress.zip.message}</p>
      )}
      <input {...register("shippingAddress.floor")} placeholder="קומה" />

      <div>
        <label>
          <input type="checkbox" {...register("sameAsBilling")} />
          כתובת חיוב זהה לכתובת משלוח
        </label>
      </div>

      {!sameAsBilling && (
        <>
          <h2>כתובת חיוב</h2>
          <input {...register("billingAddress.street")} placeholder="רחוב" />
          <input {...register("billingAddress.city")} placeholder="עיר" />
          <input {...register("billingAddress.zip")} placeholder="מיקוד" />
          <input {...register("billingAddress.floor")} placeholder="קומה" />
        </>
      )}

      <button type="submit">שלח הזמנה</button>
    </form>
  );
}

הסבר:
- סכמה מקוננת עם addressSchema שמשמש גם למשלוח וגם לחיוב
- watch("sameAsBilling") מאפשר להציג/להסתיר את כתובת החיוב בזמן אמת
- בשליחה, אם sameAsBilling מסומן, מעתיקים את כתובת המשלוח לחיוב


פתרון תרגיל 3 - טופס עם מערך דינמי

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

const recipeSchema = z.object({
  name: z.string().min(2, "שם מתכון נדרש"),
  description: z.string().min(10, "תיאור חייב להכיל לפחות 10 תווים"),
  prepTime: z.number().min(1, "זמן הכנה חייב להיות לפחות דקה"),
  difficulty: z.enum(["easy", "medium", "hard"]),
  category: z.string().min(1, "קטגוריה נדרשת"),
  ingredients: z
    .array(
      z.object({
        name: z.string().min(1, "שם מרכיב נדרש"),
        quantity: z.string().min(1, "כמות נדרשת"),
        unit: z.string().min(1, "יחידה נדרשת"),
      })
    )
    .min(1, "לפחות מרכיב אחד נדרש")
    .max(20, "מקסימום 20 מרכיבים"),
  steps: z
    .array(z.object({ text: z.string().min(1, "שלב לא יכול להיות ריק") }))
    .min(1, "לפחות שלב אחד נדרש"),
});

type RecipeData = z.infer<typeof recipeSchema>;

function RecipeForm() {
  const {
    register,
    control,
    handleSubmit,
    watch,
    formState: { errors },
  } = useForm<RecipeData>({
    resolver: zodResolver(recipeSchema),
    defaultValues: {
      name: "",
      description: "",
      prepTime: 30,
      difficulty: "medium",
      category: "",
      ingredients: [{ name: "", quantity: "", unit: "גרם" }],
      steps: [{ text: "" }],
    },
  });

  const ingredients = useFieldArray({ control, name: "ingredients" });
  const steps = useFieldArray({ control, name: "steps" });

  const formValues = watch();

  const onSubmit = (data: RecipeData) => {
    console.log("מתכון:", data);
  };

  const difficultyLabels = { easy: "קל", medium: "בינוני", hard: "קשה" };

  return (
    <div style={{ display: "flex", gap: "24px" }}>
      <form onSubmit={handleSubmit(onSubmit)} style={{ flex: 1 }}>
        <h2>יצירת מתכון</h2>

        <div>
          <label>שם המתכון</label>
          <input {...register("name")} />
          {errors.name && <p style={{ color: "red" }}>{errors.name.message}</p>}
        </div>

        <div>
          <label>תיאור</label>
          <textarea {...register("description")} />
          {errors.description && <p style={{ color: "red" }}>{errors.description.message}</p>}
        </div>

        <div>
          <label>זמן הכנה (דקות)</label>
          <input type="number" {...register("prepTime", { valueAsNumber: true })} />
        </div>

        <div>
          <label>רמת קושי</label>
          <select {...register("difficulty")}>
            <option value="easy">קל</option>
            <option value="medium">בינוני</option>
            <option value="hard">קשה</option>
          </select>
        </div>

        <div>
          <label>קטגוריה</label>
          <input {...register("category")} />
        </div>

        <h3>מרכיבים</h3>
        {ingredients.fields.map((field, index) => (
          <div key={field.id} style={{ display: "flex", gap: "8px", marginBottom: "8px" }}>
            <input {...register(`ingredients.${index}.name`)} placeholder="שם" />
            <input {...register(`ingredients.${index}.quantity`)} placeholder="כמות" />
            <select {...register(`ingredients.${index}.unit`)}>
              <option value="גרם">גרם</option>
              <option value="כוס">כוס</option>
              <option value="כף">כף</option>
              <option value="כפית">כפית</option>
              <option value="יחידה">יחידה</option>
            </select>
            {ingredients.fields.length > 1 && (
              <button type="button" onClick={() => ingredients.remove(index)}>X</button>
            )}
          </div>
        ))}
        {errors.ingredients?.root && (
          <p style={{ color: "red" }}>{errors.ingredients.root.message}</p>
        )}
        <button
          type="button"
          onClick={() => ingredients.append({ name: "", quantity: "", unit: "גרם" })}
          disabled={ingredients.fields.length >= 20}
        >
          הוסף מרכיב
        </button>

        <h3>שלבי הכנה</h3>
        {steps.fields.map((field, index) => (
          <div key={field.id} style={{ display: "flex", gap: "8px", marginBottom: "8px" }}>
            <span>{index + 1}.</span>
            <textarea {...register(`steps.${index}.text`)} placeholder={`שלב ${index + 1}`} />
            {steps.fields.length > 1 && (
              <button type="button" onClick={() => steps.remove(index)}>X</button>
            )}
          </div>
        ))}
        <button type="button" onClick={() => steps.append({ text: "" })}>
          הוסף שלב
        </button>

        <button type="submit" style={{ display: "block", marginTop: "16px" }}>
          שמור מתכון
        </button>
      </form>

      <div style={{ flex: 1, padding: "16px", backgroundColor: "#f9f9f9", borderRadius: "8px" }}>
        <h2>תצוגה מקדימה</h2>
        <h3>{formValues.name || "שם המתכון"}</h3>
        <p>{formValues.description || "תיאור..."}</p>
        <p>זמן הכנה: {formValues.prepTime} דקות | קושי: {difficultyLabels[formValues.difficulty]}</p>
        <h4>מרכיבים:</h4>
        <ul>
          {formValues.ingredients?.map((ing, i) => (
            <li key={i}>
              {ing.quantity} {ing.unit} {ing.name}
            </li>
          ))}
        </ul>
        <h4>הוראות הכנה:</h4>
        <ol>
          {formValues.steps?.map((step, i) => (
            <li key={i}>{step.text || "..."}</li>
          ))}
        </ol>
      </div>
    </div>
  );
}

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


פתרון תרגיל 4 - טופס רב-שלבי

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

const step1Schema = z.object({
  name: z.string().min(2, "שם נדרש"),
  email: z.string().email("אימייל לא תקין"),
  phone: z.string().min(9, "טלפון לא תקין"),
});

const step2Schema = z.object({
  categories: z.array(z.string()).min(1, "בחר לפחות קטגוריה אחת"),
  frequency: z.enum(["daily", "weekly", "monthly"]),
  language: z.string(),
});

const step3Schema = z.object({
  cardNumber: z.string().regex(/^\d{16}$/, "מספר כרטיס חייב להכיל 16 ספרות"),
  expiry: z.string().regex(/^\d{2}\/\d{2}$/, "פורמט MM/YY"),
  cvv: z.string().regex(/^\d{3}$/, "CVV חייב להכיל 3 ספרות"),
});

const fullSchema = step1Schema.merge(step2Schema).merge(step3Schema);
type FullFormData = z.infer<typeof fullSchema>;

const schemas = [step1Schema, step2Schema, step3Schema];
const stepTitles = ["פרטים אישיים", "העדפות", "תשלום", "סיכום"];

function MultiStepForm() {
  const [step, setStep] = useState(0);

  const form = useForm<FullFormData>({
    resolver: zodResolver(schemas[step] || fullSchema),
    defaultValues: {
      name: "",
      email: "",
      phone: "",
      categories: [],
      frequency: "weekly",
      language: "he",
      cardNumber: "",
      expiry: "",
      cvv: "",
    },
    mode: "onBlur",
  });

  const { handleSubmit, watch, trigger, getValues } = form;
  const values = watch();

  // שמירת טיוטה ב-localStorage
  useEffect(() => {
    const subscription = watch((data) => {
      localStorage.setItem("formDraft", JSON.stringify(data));
    });
    return () => subscription.unsubscribe();
  }, [watch]);

  // טעינת טיוטה
  useEffect(() => {
    const draft = localStorage.getItem("formDraft");
    if (draft) {
      form.reset(JSON.parse(draft));
    }
  }, []);

  const nextStep = async () => {
    const valid = await trigger();
    if (valid) setStep((s) => Math.min(s + 1, 3));
  };

  const prevStep = () => setStep((s) => Math.max(s - 1, 0));

  const onSubmit = (data: FullFormData) => {
    console.log("נתונים סופיים:", data);
    localStorage.removeItem("formDraft");
    alert("הטופס נשלח בהצלחה!");
  };

  return (
    <div>
      <h2>טופס הרשמה</h2>

      <div style={{ display: "flex", gap: "4px", marginBottom: "24px" }}>
        {stepTitles.map((title, i) => (
          <div
            key={i}
            style={{
              flex: 1,
              padding: "8px",
              textAlign: "center",
              backgroundColor: i <= step ? "#3b82f6" : "#e5e7eb",
              color: i <= step ? "white" : "#666",
              borderRadius: "4px",
            }}
          >
            {title}
          </div>
        ))}
      </div>

      <form onSubmit={handleSubmit(onSubmit)}>
        {step === 0 && (
          <div>
            <h3>פרטים אישיים</h3>
            <input {...form.register("name")} placeholder="שם" />
            {form.formState.errors.name && (
              <p style={{ color: "red" }}>{form.formState.errors.name.message}</p>
            )}
            <input {...form.register("email")} placeholder="אימייל" />
            {form.formState.errors.email && (
              <p style={{ color: "red" }}>{form.formState.errors.email.message}</p>
            )}
            <input {...form.register("phone")} placeholder="טלפון" />
            {form.formState.errors.phone && (
              <p style={{ color: "red" }}>{form.formState.errors.phone.message}</p>
            )}
          </div>
        )}

        {step === 1 && (
          <div>
            <h3>העדפות</h3>
            <div>
              {["טכנולוגיה", "עיצוב", "שיווק", "ניהול"].map((cat) => (
                <label key={cat} style={{ display: "block" }}>
                  <input type="checkbox" value={cat} {...form.register("categories")} />
                  {cat}
                </label>
              ))}
            </div>
            <select {...form.register("frequency")}>
              <option value="daily">יומי</option>
              <option value="weekly">שבועי</option>
              <option value="monthly">חודשי</option>
            </select>
          </div>
        )}

        {step === 2 && (
          <div>
            <h3>פרטי תשלום</h3>
            <input {...form.register("cardNumber")} placeholder="מספר כרטיס" />
            {form.formState.errors.cardNumber && (
              <p style={{ color: "red" }}>{form.formState.errors.cardNumber.message}</p>
            )}
            <input {...form.register("expiry")} placeholder="MM/YY" />
            <input {...form.register("cvv")} placeholder="CVV" />
          </div>
        )}

        {step === 3 && (
          <div>
            <h3>סיכום</h3>
            <p>שם: {values.name}</p>
            <p>אימייל: {values.email}</p>
            <p>טלפון: {values.phone}</p>
            <p>קטגוריות: {values.categories?.join(", ")}</p>
            <p>תדירות: {values.frequency}</p>
            <p>כרטיס: ****{values.cardNumber?.slice(-4)}</p>
          </div>
        )}

        <div style={{ display: "flex", gap: "8px", marginTop: "16px" }}>
          {step > 0 && (
            <button type="button" onClick={prevStep}>
              חזרה
            </button>
          )}
          {step < 3 ? (
            <button type="button" onClick={nextStep}>
              הבא
            </button>
          ) : (
            <button type="submit">אשר ושלח</button>
          )}
        </div>
      </form>
    </div>
  );
}

הסبר:
- כל שלב מולידט עם סכמה משלו - כך אפשר לעבור לשלב הבא רק אם השלב הנוכחי תקין
- trigger() מריץ ולידציה ידנית ומחזיר אם תקין
- watch() עם subscription שומר טיוטה ב-localStorage בכל שינוי
- בדף סיכום מוצגים כל הנתונים שהוזנו


פתרון תרגיל 5 - טופס עם ולידציה אסינכרונית

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

const takenUsernames = ["admin", "user", "test", "demo"];
const takenEmails = ["admin@example.com", "test@example.com"];

async function checkUsername(username: string): Promise<boolean> {
  await new Promise((r) => setTimeout(r, 800));
  return !takenUsernames.includes(username.toLowerCase());
}

async function checkEmail(email: string): Promise<boolean> {
  await new Promise((r) => setTimeout(r, 800));
  return !takenEmails.includes(email.toLowerCase());
}

const schema = z.object({
  username: z.string().min(3, "לפחות 3 תווים").max(20, "מקסימום 20 תווים"),
  email: z.string().email("אימייל לא תקין"),
  password: z.string().min(8, "לפחות 8 תווים"),
});

type FormData = z.infer<typeof schema>;

function AsyncValidationForm() {
  const {
    register,
    handleSubmit,
    watch,
    setError,
    clearErrors,
    formState: { errors, isSubmitting },
  } = useForm<FormData>({
    resolver: zodResolver(schema),
    mode: "onBlur",
  });

  const [checkingUsername, setCheckingUsername] = useState(false);
  const [checkingEmail, setCheckingEmail] = useState(false);
  const [usernameAvailable, setUsernameAvailable] = useState<boolean | null>(null);
  const [emailAvailable, setEmailAvailable] = useState<boolean | null>(null);
  const usernameTimer = useRef<number | null>(null);
  const emailTimer = useRef<number | null>(null);

  const username = watch("username");
  const email = watch("email");

  // Debounced username check
  useEffect(() => {
    if (!username || username.length < 3) {
      setUsernameAvailable(null);
      return;
    }

    setCheckingUsername(true);
    if (usernameTimer.current) clearTimeout(usernameTimer.current);

    usernameTimer.current = window.setTimeout(async () => {
      const available = await checkUsername(username);
      setUsernameAvailable(available);
      setCheckingUsername(false);
      if (!available) {
        setError("username", { message: "שם המשתמש תפוס" });
      } else {
        clearErrors("username");
      }
    }, 500);

    return () => {
      if (usernameTimer.current) clearTimeout(usernameTimer.current);
    };
  }, [username, setError, clearErrors]);

  // Debounced email check
  useEffect(() => {
    if (!email || !email.includes("@")) {
      setEmailAvailable(null);
      return;
    }

    setCheckingEmail(true);
    if (emailTimer.current) clearTimeout(emailTimer.current);

    emailTimer.current = window.setTimeout(async () => {
      const available = await checkEmail(email);
      setEmailAvailable(available);
      setCheckingEmail(false);
      if (!available) {
        setError("email", { message: "האימייל כבר רשום" });
      } else {
        clearErrors("email");
      }
    }, 500);

    return () => {
      if (emailTimer.current) clearTimeout(emailTimer.current);
    };
  }, [email, setError, clearErrors]);

  const onSubmit = (data: FormData) => {
    console.log("נשלח:", data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <h2>הרשמה עם ולידציה אסינכרונית</h2>

      <div>
        <label>שם משתמש</label>
        <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
          <input {...register("username")} />
          {checkingUsername && <span>בודק...</span>}
          {!checkingUsername && usernameAvailable === true && (
            <span style={{ color: "green" }}>זמין</span>
          )}
        </div>
        {errors.username && <p style={{ color: "red" }}>{errors.username.message}</p>}
      </div>

      <div>
        <label>אימייל</label>
        <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
          <input type="email" {...register("email")} />
          {checkingEmail && <span>בודק...</span>}
          {!checkingEmail && emailAvailable === true && (
            <span style={{ color: "green" }}>זמין</span>
          )}
        </div>
        {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>

      <button type="submit" disabled={isSubmitting || checkingUsername || checkingEmail}>
        הירשם
      </button>
    </form>
  );
}

הסبר:
- debounce עם useRef ו-setTimeout - הבדיקה רצה רק 500ms אחרי שהמשתמש הפסיק להקליד
- setError ו-clearErrors מאפשרים להוסיף/להסיר שגיאות ידנית
- מוצג סטטוס בדיקה (spinner) וזמינות (ירוק) ליד כל שדה


תשובות לשאלות

  1. ביצועים של React Hook Form: React Hook Form משתמש ב-uncontrolled inputs (refs) ולא ב-useState. זה אומר שהקלדה בשדה לא גורמת לרנדר מחדש של הטופס כולו. Formik, לעומת זאת, משתמש ב-controlled inputs עם useState, מה שגורם לרנדר בכל שינוי. בטפסים עם הרבה שדות, ההבדל משמעותי.

  2. Zod מול ולידציה ישירה: Zod מספקת: טיפוסי TypeScript אוטומטיים (z.infer), ולידציות מורכבות בקלות (refine, transform), שימוש חוזר בסכמות (merge, extend), ולידציה גם בצד השרת עם אותה סכמה. ולידציה ישירה ב-register מוגבלת לשדה בודד ולא תומכת בולידציה חוצת-שדות.

  3. onChange מול onBlur: mode: "onChange" מריץ ולידציה בכל הקלדה - UX תגובתי אבל יותר רנדרים. mode: "onBlur" מריץ ולידציה רק כשעוזבים שדה - פחות רנדרים אבל השגיאות מופיעות רק אחרי שהמשתמש עזב את השדה. mode: "onTouched" הוא פשרה טובה - ולידציה ראשונה ב-onBlur, ואז onChange.

  4. ביצועים של useFieldArray: useFieldArray משתמש ב-key ייחודי (field.id) לכל פריט ומעדכן רק את הפריטים שהשתנו. הוא לא מרנדר את כל המערך בכל שינוי. בנוסף, register עם indexes (items.${index}.name) מאפשר עדכון ממוקד ללא רנדר של פריטים אחרים.

  5. z.infer מול interface: z.infer מבטיח שהטיפוס תמיד מסונכרן עם הסכמה - אם מוסיפים שדה לסכמה, הטיפוס מתעדכן אוטומטית. עם interface ידני, אפשר לשכוח לעדכן את הטיפוס כשמשנים את הסכמה, מה שגורם לבאגים שקשה למצוא. Single source of truth.