טפסים עם שדות דינמיים הם תרחיש נפוץ במוצרי SaaS ובמרקטפלייסים. הלקוח מוסיף נוסעים, פריטים או משתפי פעולה, והמפתח מתמודד עם אתגרים של ולידציה, ביצועים ו-UX. במוצר SaaS טיפוסי, טופס עשוי להכיל עד 50 חלקים דינמיים. ללא יישום נכון, כל הוספת שדה גורמת לתגובת שרשרת של רינדורים מחדש — INP יורד ל-500 אלפיות השנייה, מה שקריטי עבור Core Web Vitals. אנו בונים טפסים כאלה ב-React באמצעות const passengerSchema = z.object({ firstName: z.string().min(2, 'Минимум 2 символа'), lastName: z.string().min(2), birthDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), passport: z.string().regex(/^\d{4}\s\d{6}$/), }); ו-Zod, ומבטיחים פעולה יציבה ללא קשר למספר השדות.
למה יישום נכון של שדות דינמיים חשוב
שגיאות במהלך פעולות הוספה/הסרה מובילות לשאילתות N+1, רינדורים מיותרים ובאגים בשמירת נתונים. הצוות שלנו, עם ניסיון של למעלה מעשור, פיתח גישה שממזערת סיכונים אלה. לדוגמה, במרקטפלייס אחד ייעלנו טופס רישום עם 30 שדות — זמן הטעינה ירד מ-4 שניות ל-0.8 שניות בזכות עצי React.memo וולידציה עצלה. בפרויקט אחר עם 5,000 טפסים פעילים, הפחתנו את LCP ב-80% — מ-3.2 שניות ל-0.6 שניות.
"טפסים דינמיים הם מפתח ל-UX גמיש, אך היישום שלהם דורש תשומת לב לפרטים: כל שדה נוסף יכול לעלות אלפיות שנייה של INP." — מהנדס בכיר מפרויקט מסחר אלקטרוני גדול.
שימוש ב-import { useForm, useFieldArray, Controller } from 'react-hook-form'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; const passengerSchema = z.object({ firstName: z.string().min(2), lastName: z.string().min(2), birthDate: z.string(), passport: z.string().regex(/^\d{4}\s\d{6}$/), }); const formSchema = z.object({ passengers: z.array(passengerSchema).min(1).max(9), }); type FormValues = z.infer<typeof formSchema>; export function PassengersForm() { const { control, register, handleSubmit, formState: { errors } } = useForm<FormValues>({ resolver: zodResolver(formSchema), defaultValues: { passengers: [{}] }, }); const { fields, append, remove, move } = useFieldArray({ control, name: 'passengers', }); return ( <form onSubmit={handleSubmit(console.log)}> <div className="space-y-4"> {fields.map((field, index) => ( <div key={field.id} className="border rounded-xl p-4 relative"> <div className="flex items-center justify-between mb-3"> <h3 className="font-medium">Пассажир {index + 1}</h3> {fields.length > 1 && ( <button type="button" onClick={() => remove(index)} className="text-red-500 text-sm hover:text-red-700" > Удалить </button> )} </div> <div className="grid grid-cols-2 gap-3"> <div> <label className="block text-sm mb-1">Имя</label> <input {...register(`passengers.${index}.firstName`)} className="input-field" /> {errors.passengers?.[index]?.firstName && ( <p className="text-red-500 text-xs mt-1"> {errors.passengers[index].firstName?.message} </p> )} </div> <div> <label className="block text-sm mb-1">Фамилия</label> <input {...register(`passengers.${index}.lastName`)} className="input-field" /> </div> <div> <label className="block text-sm mb-1">Паспорт</label> <input {...register(`passengers.${index}.passport`)} placeholder="1234 567890" className="input-field" /> </div> </div> </div> ))} </div> {fields.length < 9 && ( <button type="button" onClick={() => append({})} className="btn-secondary mt-4 w-full" > + Добавить пассажира </button> )} <button type="submit" className="btn-primary mt-4 w-full"> Продолжить </button> </form> ); } יעיל פי 3 מניהול מערכים ידני: פחות קוד, פחות רינדורים מחדש, תמיכה מובנית במיון. השוו:
| גישה | רינדורים מחדש בעת שינוי | מורכבות תחזוקה | גרירה ושחרור |
|---|---|---|---|
| useFieldArray | רק שדות ששונו | נמוכה | מובנית |
| State ידני | כל השדות | גבוהה | דורש יישום נפרד |
איך אנו מיישמים ולידציה ועיבוד נתונים
אנו משתמשים בסכמת Zod עבור כל רכיב במערך. זה מספק טיפוסי TypeScript וולידציה בצד השרת. דוגמת סכמה לנוסעים:
const passengerSchema = z.object({
firstName: z.string().min(2, 'Минимум 2 символа'),
lastName: z.string().min(2),
birthDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
passport: z.string().regex(/^\d{4}\s\d{6}$/),
});הולידציה בצד הלקוח מתבצעת מיידית, וצד השרת משכפל אותה. כך מושגת שלמות נתונים של 100%.
יישום באמצעות React Hook Form useFieldArray
import { useForm, useFieldArray, Controller } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
const passengerSchema = z.object({
firstName: z.string().min(2),
lastName: z.string().min(2),
birthDate: z.string(),
passport: z.string().regex(/^\d{4}\s\d{6}$/),
});
const formSchema = z.object({
passengers: z.array(passengerSchema).min(1).max(9),
});
type FormValues = z.infer<typeof formSchema>;
export function PassengersForm() {
const {
control,
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { passengers: [{}] },
});
const { fields, append, remove, move } = useFieldArray({
control,
name: 'passengers',
});
return (
<form onSubmit={handleSubmit(console.log)}>
<div className="space-y-4">
{fields.map((field, index) => (
<div key={field.id} className="border rounded-xl p-4 relative">
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium">Пассажир {index + 1}</h3>
{fields.length > 1 && (
<button
type="button"
onClick={() => remove(index)}
className="text-red-500 text-sm hover:text-red-700"
>
Удалить
</button>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm mb-1">Имя</label>
<input {...register(`passengers.${index}.firstName`)} className="input-field" />
{errors.passengers?.[index]?.firstName && (
<p className="text-red-500 text-xs mt-1">
{errors.passengers[index].firstName?.message}
</p>
)}
</div>
<div>
<label className="block text-sm mb-1">Фамилия</label>
<input {...register(`passengers.${index}.lastName`)} className="input-field" />
</div>
<div>
<label className="block text-sm mb-1">Паспорт</label>
<input {...register(`passengers.${index}.passport`)} placeholder="1234 567890" className="input-field" />
</div>
</div>
</div>
))}
</div>
{fields.length < 9 && (
<button
type="button"
onClick={() => append({})}
className="btn-secondary mt-4 w-full"
>
+ Добавить пассажира
</button>
)}
<button type="submit" className="btn-primary mt-4 w-full">
Продолжить
</button>
</form>
);
} מיון בגרירה ושחרור
עבור טפסים שבהם סדר הפריטים חשוב (רשימת מוצרים בהזמנה, עדיפויות משימות):
import { DndContext, closestCenter } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
function SortableFieldItem({ field, index, onRemove }: SortableItemProps) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: field.id });
return (
<div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition }}>
<div {...attributes} {...listeners} className="cursor-grab p-1">⠿</div>
{/* поля элемента */}
</div>
);
}
// В основном компоненте:
function handleDragEnd(event) {
const { active, over } = event;
if (active.id !== over.id) {
const from = fields.findIndex(f => f.id === active.id);
const to = fields.findIndex(f => f.id === over.id);
move(from, to);
}
} תהליך העבודה
| שלב | מה אנו עושים | תוצאה |
|---|---|---|
| אנליטיקה | לומדים תרחישי מילוי, מספר שדות מקסימלי, דרישות ולידציה | מפרט טופס |
| עיצוב | בוחרים סטאק (React Hook Form + Zod + @dnd-kit), מתכננים סכמת נתונים, אב-טיפוס UX | תיעוד API, מוקאפים |
| יישום | כותבים קוד עם useFieldArray, ולידציה, מיון | טופס עובד במאגר |
| בדיקות | בדיקות יחידה לוולידציה, בדיקות e2e עם Cypress, בדיקות עומס | כיסוי של 100%, דוח Core Web Vitals |
| פריסה | CI/CD, הגדרת ניטור, העברת אישורי גישה | טופס בייצור, הוראות לצוות |
שלב אחר שלב: איך אנו מוסיפים שדה חדש עם רינדור מינימלי
- משתמשים ב-
import { DndContext, closestCenter } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'; function SortableFieldItem({ field, index, onRemove }: SortableItemProps) { const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: field.id }); return ( <div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition }}> <div {...attributes} {...listeners} className="cursor-grab p-1">⠿</div> {/* поля элемента */} </div> ); } // В основном компоненте: function handleDragEnd(event) { const { active, over } = event; if (active.id !== over.id) { const from = fields.findIndex(f => f.id === active.id); const to = fields.findIndex(f => f.id === over.id); move(from, to); } }עם מזהים ייחודיים (useFieldArray). - עוטפים כל פריט ב-
field.idעם השוואת props עמוקה. - לוולידציה, מיישמים
React.memoעם בדיקה עצלה — הסכמה נקראת רק עבור שדות ששונו. - בעת הסרה, קודם מפעילים אנימציה על הרכיב, ואז קוראים ל-
zodResolverלאחר 300 אלפיות השנייה.
מה כלול
- קוד מקור של הטופס עם הערות
- תיעוד API (Swagger/OpenAPI)
- בדיקות (Jest + React Testing Library)
- הוראות פריסה ל-DevOps שלכם
- שבועיים של תמיכה חינם לאחר המסירה
- אחריות ל-6 חודשים על באגים שזוהו
לוח זמנים ותמחור
לוח זמנים: בין 2 ל-5 ימי עסקים, תלוי במורכבות הולידציה והאינטגרציה. התמחור מחושב באופן אישי — נעריך את הפרויקט שלכם תוך יום אחד. צרו קשר לייעוץ.
איך להימנע מטעויות נפוצות
- אל תשתמשו באינדקס המערך כ-key — זה שובר את ה-state במהלך מיון. השתמשו ב-id הייחודי מ-useFieldArray.
-
אל תגזימו עם z.union עבור טיפוסים דינמיים — זה מאט את TypeScript. עבור סכמות מקוננות, השתמשו ב-
remove. - אם השדות עולים על 50, השתמשו בווירטואליזציה (למשל, react-window). אחרת, הדפדפן עלול לפגר.
איך לנפות בעיות ביצועים?
הפעילו את React DevTools Profiler, הוסיפו לוגים ב-useEffect כדי לספור זמן רינדור. השתמשו ב-`why-did-you-render` כדי לזהות רינדורים מיותרים.המומחיות שלנו מבטיחה פעולה יציבה של הטפסים. עם למעלה מ-50 יישומים מוצלחים למרקטפלייסים, בנקים ומערכות CRM, אתם יכולים לסמוך על המסירה שלנו. בקשו פיתוח — נחזור אליכם תוך שעה. קבלו ייעוץ ליישום טפסים עם שדות דינמיים — נבחר את הסטאק המתאים לתרחיש שלכם.
קישור לתיעוד הרשמי: React Hook Form useFieldArray.







