이 기사는 SurveyJS의 후원으로 작성되었습니다. 대부분의 React 개발자가 큰 소리로 논의하지 않고 공유하는 정신 모델이 있습니다. 해당 양식은 항상 구성 요소로 간주됩니다. 이는 다음과 같은 스택을 의미합니다.

로컬 상태를 위한 React Hook Form(최소한의 재렌더링, 인체공학적 필드 등록, 필수 상호작용). 유효성 검사를 위한 Zod(입력 정확성, 경계 유효성 검사, 유형 안전 구문 분석) 백엔드에 대한 React 쿼리: 제출, 재시도, 캐싱, 서버 동기화 등.

그리고 로그인 화면, 설정 페이지, CRUD 모달 등 대부분의 양식에서 이는 정말 잘 작동합니다. 각 부분은 해당 작업을 수행하고 깔끔하게 구성되며 실제로 제품을 차별화하는 애플리케이션 부분으로 이동할 수 있습니다. 그러나 때때로 양식은 이전 답변에 의존하는 가시성 규칙이나 세 필드를 통해 계단식으로 전달되는 파생 값과 같은 항목을 축적하기 시작합니다. 전체 페이지를 건너뛰거나 누적 합계를 기준으로 표시해야 할 수도 있습니다. useWatch와 인라인 분기를 사용하여 첫 번째 조건을 처리합니다. 괜찮습니다. 그리고 또 다른. 그런 다음 Zod 스키마가 일반적인 방식으로 표현할 수 없는 교차 필드 규칙을 인코딩하기 위해 superRefine을 사용하게 됩니다. 그런 다음 단계 탐색에서 비즈니스 로직이 유출되기 시작합니다. 어느 시점에서 당신은 당신이 만든 것을 보고 그 양식이 더 이상 실제로 UI가 아니라는 것을 깨닫습니다. 이는 결정 프로세스에 가깝고 구성 요소 트리는 우연히 이를 저장한 위치입니다. 이것이 바로 React의 양식에 대한 정신적 모델이 무너지는 지점이라고 생각하며 이는 실제로 누구의 잘못도 아닙니다. RHF + Zod 스택은 설계된 목적이 뛰어납니다. 문제는 대안이 형식에 대해 완전히 다른 사고 방식을 요구하기 때문에 추상화가 문제와 일치하는 지점을 지나서 계속 사용하는 경향이 있다는 것입니다. 이 문서는 그 대안에 관한 것입니다. 이를 보여주기 위해 정확히 동일한 다단계 양식을 두 번 작성하겠습니다.

제출을 위해 React Hook Form + Zod를 React 쿼리에 연결하여, 양식을 구성 요소 트리가 아닌 간단한 JSON 스키마인 데이터로 처리하는 SurveyJS를 사용합니다.

동일한 요구 사항, 동일한 조건부 논리, 결국 동일한 API 호출. 그런 다음 이동한 것과 남아 있는 것을 정확히 매핑하고 어떤 모델을 언제 사용해야 할지 결정하는 실용적인 방법을 제시합니다. 우리가 만들고 있는 양식은 다음과 같습니다.

이 양식은 4단계 흐름을 사용합니다. 1단계: 세부정보

이름(필수), 이메일(필수, 유효한 형식)

2단계: 주문

단가, 수량, 세율, 파생됨: 소계, 세금, 합계.

3단계: 계정 및 피드백

계정이 있나요? (예/아니요) 예 → 사용자 이름 + 비밀번호인 경우 둘 다 필요합니다. 아니요 → 이메일은 이미 1단계에서 수집되었습니다.

만족도 평점(1~5) ≥ 4인 경우 → “무엇을 좋아하셨나요?”라고 질문하세요. ≤ 2인 경우 → “무엇을 개선할 수 있나요?”라고 질문하세요.

4단계: 검토

총합이 100보다 큰 경우에만 나타납니다. 최종 제출.

이것은 극단적이지 않습니다. 그러나 아키텍처의 차이점을 드러내는 것만으로도 충분합니다. 1부: 컴포넌트 기반(React Hook Form + Zod) 설치 npm 설치 반응 후크 형식 zod @hookform/resolvers @tanstack/react-query

조드 스키마 Zod 스키마부터 시작하겠습니다. 일반적으로 Zod 스키마에서 양식의 모양이 설정되기 때문입니다. 처음 두 단계(개인 정보 및 주문 입력)의 경우 필수 문자열, 최소값이 포함된 숫자, 열거형 등 모든 것이 간단합니다. 흥미로운 부분은 조건부 규칙을 표현하려고 할 때 시작됩니다.

"zod"에서 {z }를 가져옵니다.

import const formSchema = z.object({ firstName: z.string().min(1, "필수"), 이메일: z.string().email("잘못된 이메일"), 가격: z.number().min(0), 수량: z.number().min(1), TaxRate: z.number(), hasAccount: z.enum(["Yes", "No"]), 사용자 이름: z.string().ional(), 비밀번호: z.string().옵션(), 만족도: z.number().min(1).max(5), positiveFeedback: z.string().옵션(), 개선피드백: z.string().옵션(),}).superRefine((data, ctx) => { if (data.hasAccount === "예") { if (!data.username) { ctx.addIssue({ 코드: "custom", 경로: ["username"], 메시지: "필수" }) } if (!data.password || data.password.length < 6) { ctx.addIssue({ code: "custom", 경로: ["password"], message: "최소 6자" });

if (data.satisfaction >= 4 && !data. positiveFeedback) { ctx.addIssue({ code: "custom", path: ["PositiveFeedback"], message: "좋아요를 공유해 주세요." }); }

if (data.satisfaction <= 2 && !data.improvementFeedback) { ctx.addIssue({ 코드: "custom", 경로:["improvementFeedback"], 메시지: "개선할 점을 알려주세요." }); }});

내보내기 유형 FormData = z.infer;

사용자 이름과 비밀번호는 조건부 필수임에도 불구하고 선택 사항()으로 입력됩니다. Zod의 유형 수준 스키마는 필드가 중요한 경우를 관리하는 규칙이 아니라 객체의 모양을 설명하기 때문입니다. 조건부 요구 사항은 모양이 검증된 후 실행되고 전체 개체에 액세스할 수 있는 superRefine 내부에 있어야 합니다. 그 분리는 결함이 아닙니다. 이는 도구가 설계된 목적입니다. superRefine은 스키마 구조 자체에서 표현될 수 없을 때 교차 필드 논리가 사용되는 곳입니다. 여기서 주목할만한 점은 이 스키마가 표현하지 않는 것입니다. 페이지 개념도 없고, 어떤 필드가 어느 시점에 표시되는지에 대한 개념도 없으며, 탐색 개념도 없습니다. 그 모든 것은 다른 곳에 살 것입니다. 양식 구성 요소

import { useForm, useWatch } from "react-hook-form";import { zodResolver } from "@hookform/resolvers/zod";import { useMutation } from "@tanstack/react-query";import { useState, useMemo } from "react";import { formSchema, type FormData } from "./schema";

const STEPS = ["세부정보", "주문", "계정", "검토"];

type OrderPayload = FormData & { 소계: 숫자; 세금: 번호; 총계: 개수 };

내보내기 함수 RHFMultiStepForm() { const [step, setStep] = useState(0);

const 돌연변이 = useMutation({ mutationFn: 비동기(페이로드: OrderPayload) => { const res = 가져오기를 기다립니다("/api/orders", { 방법: "POST", 헤더: { "Content-Type": "application/json" }, 본문: JSON.stringify(페이로드), }); if (!res.ok) throw new Error("제출 실패"); res.json()을 반환합니다. }, });

const { 등록, 제어, handlerSubmit, formState: { 오류 }, } = useForm({ 확인자: zodResolver(formSchema), defaultValues: { 가격: 0, 수량: 1, 세금율: 0.1, 만족도: 3, hasAccount: "No", }, }); const 가격 = useWatch({ 제어, 이름: "가격" }); const 수량 = useWatch({ 제어, 이름: "수량" }); const TaxRate = useWatch({ control, name: "taxRate" }); const hasAccount = useWatch({ control, name: "hasAccount" }); const 만족 = useWatch({ 제어, 이름: "만족" }); const subtotal = useMemo(() => (가격 ?? 0) * (수량 ?? 1), [가격, 수량]); const 세금 = useMemo(() => 소계 * (taxRate ?? 0), [소계, TaxRate]); const total = useMemo(() => 소계 + 세금, [소계, 세금]); const onSubmit = (data: FormData) => mutation.mutate({ ...data, 소계, 세금, 합계 }); const showSubmit = (단계 === 2 && 총계 < 100) || (단계 === 3 && 총 >= 100)

return (

{step === 0 && ( <> )}

{step === 1 && ( <>

소계: {subtotal}
세금: {tax}
총액: {total}
)}

{step === 2 && ( <>

{hasAccount === "예" && ( <> )}

{만족도 >= 4 && (