Введение
Многошаговую форму с очень большим количеством полей ввода сложно поддерживать даже в исходном виде, а при добавлении таких функций, как временное сохранение и настройка начальных значений, она очень быстро становится сложной. Поэтому при работе с формами необходима эффективная «стратегия». В этой статье я обобщу подход к стратегическому управлению сложными формами в среде TypeScript и React с использованием react-hook-form (RHF), Zod и Jotai.
Когда сложные формы начинают разрушаться?
По мере увеличения формы начинают возникать следующие проблемы.
1. Компоненты становятся перегруженными.
— По мере увеличения количества полей ввода, логики валидации, условного отображения и обработки ошибок компонент, отвечающий за управление формой, экспоненциально разрастается.
2. Значения теряются или создаются ненужные данные.
— При наличии условных полей значения полей, не соответствующих текущим условиям, могут быть потеряны или сохранены без необходимости.
3. Структура быстро усложняется по мере увеличения количества моментов обработки.
— При поддержке временного сохранения или наличии нескольких условных полей код быстро усложняется, поскольку ему приходится обрабатывать множество вариантов в зависимости от того, когда значения сохраняются или изменяются.
Настройка стратегии управления
Вместо того чтобы решать перечисленные выше проблемы по отдельности, я хотел устранить их на основе эффективных принципов управления. В результате я сформулировал следующие стратегии.
1. Упростить общий код, минимизировав дублирование логики
2. При изменении условий сбрасывать значения ненужных условных полей.
3. Строить логику обработки ответов на основе значений условий и момента обработки.
4. Проверять введённые значения на каждом этапе, а затем ещё раз выполнять полную валидацию при сохранении итоговых данных.
Подробное рассмотрение стратегии
(1) Определить общие типы
// schemas/common.ts
import { z } from "zod";
export const MemberType = z.enum(["personal", "employee", "freelancer", "business"]);
export type MemberType = z.infer<typeof MemberType>;
export const basicProfileSchema = z.object({
nickname: z.string().min(2, "닉네임은 2자 이상이어야 합니다.").max(20),
...
});
(2) Разделить код для каждого этапа и использовать extend() из RHF и discriminatedUnion из Zod
// schemas/steps/job.schema.ts
import { z } from "zod";
import { MemberType } from "../common";
const baseJob = z.object({
memberType: MemberType,
...
});
const employeeJob = baseJob.extend({
memberType: z.literal("employee"),
...
});
const freelancerJob = baseJob.extend({
memberType: z.literal("freelancer"),
...
});
const businessJob = baseJob.extend({
memberType: z.literal("business"),
...
});
const personalJob = baseJob.extend({
memberType: z.literal("personal"),
...
});
export const jobStepSchema = z.discriminatedUnion("memberType", [
personalJob,
employeeJob,
freelancerJob,
businessJob,
]);
export type JobStepForm = z.infer<typeof jobStepSchema>;
(3) Объединить их в полную модель
// schemas/profile.schema.ts
import { z } from "zod";
import { basicProfileSchema } from "./common";
import { jobStepSchema } from "./steps/job.schema";
export const profileSchema = z.object({
profile: basicProfileSchema,
job: jobStepSchema,
...
});
export type ProfileForm = z.infer<typeof profileSchema>;
На этом этапе значения при необходимости можно проверять с помощью функций refine() и superRefine() из Zod, а Jotai использовать для хранения текущего этапа и временных значений.
Пример реализации компонента
Компонент реализуется следующим образом. Если существуют временные значения, они используются при начальной настройке. Временное сохранение, а также сохранение текущего и завершённого этапов выполняются при отправке формы текущего этапа.
// steps/Job.tsx
import React, { useEffect } from "react";
import { useAtom } from "jotai";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { draftAtom, completedStepsAtom, currentStepAtom } from "../state/profileWizard.atoms";
import { mergeDraft } from "../state/mergeDraft";
import { jobStepSchema, type JobStepForm } from "../schemas/steps/Job.schema";
export function Job() {
const [draft, setDraft] = useAtom(draftAtom); // 임시 저장값 atom
const [completedSteps, setCompletedSteps] = useAtom(completedStepsAtom); // 완료된 단계를 저장하는 atom
const [currentStep, setCurrentStep] = useAtom(currentStepAtom); // 현재 단계를 나타내는 atom
const form = useForm<JobStepForm>({
resolver: zodResolver(jobStepSchema),
defaultValues: {
...
},
});
const memberType = form.watch("memberType");
const handleSubmit = (values) => {
// 1) 현재 스텝 값을 draft에 저장
setDraft(mergeDraft(draft, { job: values }));
// 2) 스텝 완료 처리
const nextCompleted = new Set(completedSteps);
nextCompleted.add(4); setCompletedSteps(nextCompleted);
// 3) 다음 스텝 이동
setCurrentStep((currentStep + 1) as any);
}
// 스텝 진입 시 임시 저장값으로 초기화
useEffect(() => {
if (!!draft?.job) {
form.reset(draft.job);
}
}, []);
// 조건 변경 시 불필요한 조건부 필드의 값 초기화 (관리 전략 2)
useEffect(() => {
const base = form.getValues();
if (memberType === "employee") {
form.reset({
memberType,
role: base.role,
...
} as JobStepForm);
}
if (memberType === "freelancer") {
form.reset({
memberType,
role: base.role,
...
} as JobStepForm);
}
if (memberType === "business") {
form.reset({
memberType,
role: base.role,
...
} as JobStepForm);
}
if (memberType === "personal") {
form.reset({
memberType,
role: base.role,
...
} as JobStepForm);
}
}, [memberType]);
return (
...
)
};
Заключение
На примере приведённого выше краткого кода мы рассмотрели, как разработать и применить стратегию работы со сложными формами. Благодаря предварительной разработке базовых принципов и стратегий стало возможно не только эффективнее управлять формами, но и значительно сократить время разработки и сопровождения. Этот опыт показал мне, что качественный код также может быть результатом правильно выбранных правил.
Май