Multistep Form with Next.js(w. RHF + Zod)

김 주현·2024년 5월 23일

공시락 Gongsilock

목록 보기
3/6

여러 단계를 걸쳐서 폼을 제출하는 형태는 일전에 많이 다뤄봤었지만, Form 형태로 다루면서 RHF + Zod의 조합은 써보지 않았기에 기록용으로 남겨놓는 포스팅 !


어떻게 라우팅을 분리할 것인가?

React에서는 SPA이기 때문에 경로에 대해서 그다지 신경쓰지 않았는데, Next.js로 넘어오니 경로에 대한 고민이 생겼다. 아무래도 파일 단위 라우팅은 처음이다 보니까, 여러 가지가 고민이 되더라. 내가 생각한 흐름은 3가지였다.

(1) Page 단위

경로: /step/a, /step/b, /step/c, /step/d

Next.js의 파일 단위 라우팅을 그대로 가져와서 쓰는 방법. 처음에는 요 방법이 괜찮은 것 같았다.

  • (장점) 각 필드셋에 대한 관리 역할을 구분해줄 수 있음.
  • (장점) SSG로 생성될 것이므로 빠른 TTV를 가지게 될 것. 폼 입력하는 데 시간이 있을 테니 TTI는 느려도 상관 없고.

그러나,, 다음과 같은 단점이 있었다.

  • (단점) Hard Navigation을 타고 오면 오류 제어 해야 함
  • (단점) 다음 페이지로 넘어갈 때 폼 제출 관리하기 까다로움

Next.js로 넘어오면서 살짝 귀찮아진 건 라우팅 히스토리에 대한 관리가 좀 더 엄격해야 한다는 것이었다. SPA도 해주긴 했어야 했지만, MPA는 Hard Navigation에 대한 핸들링이 좀 더 필요하다고 판단했다. 그리고 할 수 있으면 핸들링을 안 하고 싶었음

그래서,, 그러면 한 페이지 내에서 경로만 push해주며 Params로 제어하는 방식을 떠올렸다.

(2) searchParams

경로: ?step=a, ?step=b, ?step=c, ?step=d

searchParams으로 현재 단계를 표시해주는 방법.

  • (장점) 한 페이지에서 관리를 할 수 있음

그렇지만,, 이 방식은 여러 문제가 있었는데.

  • (단점) 마찬가지로 Hard Navigation을 따로 제어 해야 함
  • (단점) 각 페이지가 static하게 생성되는 게 아니니까 번들 크기가 커짐
  • (단점) 클라이언트와 서버 간 불일치가 발생할 수도 있음

유저가 만약 Hard하게 해당 스탭이 없이, 또는 존재하지 않는 스탭에 대해서 들어오게 되면 이에 대해 제어를 해주어야 하고,, Next.js에서는 어쨌든 SSR이든 SSG든 사전에 페이지를 만들어주게 된다. 만약 저렇게 제어하게 되면 서버에서 랜더링하고 보내줬을 때랑 실제 클라이언트에서 렌더링한 결과가 달라질 수도 있다.

그리고 무엇보다 Next.js에서는 그냥 경로에 밀어넣는 게 안 되더라(...)

(3) Single Page, Component

경로: /create

그러므로~ 결국엔 싱글 페이지로 관리하는 게 낫겠다는 판단이었다.

  • (장점) 한 경로 안에서 처리하므로 Hard Navigation 오류 처리 필요 없음
  • (단점) 폼 데이터가 많아질수록 상태 관리가 복잡해질 수 있음
  • (단점) 이 역시 번들 사이즈가 늘어남

결국에는 Hard Navigation 오류 핸들링을 할 것인지 안 할 것인지를 선택해야 할 것 같았다. 고민하다가,, 내 판단은 그래도 Hard Navigation은 하고 싶지 않았다. 초기 로딩 증가로 인한 패널티보다 Hard Navigation의 예외 처리 비용이 더 클 것이라고 판단했기 때문.

그러므로 한 경로로 진행하되, 코드 스프릿으로 번들 크기를 나누고자 했다.

코드 스플릿은 nextjs의 기능인 dynamic을 써먹으면 될 것 같았다. lazy와 suspense를 확장해서 Next.js에 맞게 최적화한 기능이므로 적절할 것으로 판단. promise를 반환하기 때문에 fallback UI가 필요한데, 폼 제출 과정에서 해당 fallback UI 표시는 불필요하다고 판단했기 때문에 굳이 제어하진 않을 예정(잦은 fallback ui는 ux를 떨어뜨린다고 생각). 이후 실제로 사용하면서 해당 부분에 대해 의견을 모으고 진행하는 게 맞을 것 같다.


폼 제출 흐름

나의 경우엔 총 4단계의 폼 제출이 있었다. 각각의 폼은 자기 필드셋에 대한 유효성 검사를 담당하며, 이 4단계의 폼을 가지고 있는 부모에서 서버에 보낼 폼 데이터를 관리해주는 흐름으로 갔다. 참고로 각각의 폼에선 서버에 유효성 검사를 요청하는 흐름은 없었다.

메인 폼

흔하게 쓰이는 패턴이므로 코드 위주로 살펴보겠음!

Main Form

export default function Page() {
  const router = useRouter();

  const [formDataForSubmit, setFormDataForSubmit] = useState(initialFormData);
  const [currentStepId, setCurrentStepId] = useState<CreateStep\>(CreateStep.REQUIRED);

  const isRequiredStep = currentStepId === CreateStep.REQUIRED;
  const isTemplateStep = currentStepId === CreateStep.TEMPLATE;
  const isTimetableStep = currentStepId === CreateStep.TIMETABLE;
  const isTimetableDetailStep = currentStepId === CreateStep.TIMTETABLE_DETAIL;

  const updateFormData = (data: Partial<CreateFormSchema\>) => {
    setFormDataForSubmit({ ...formDataForSubmit, ...data });
  };

  const updateCurrentStepId = (nextStep: CreateStep) => {
    setCurrentStepId(nextStep);
  };

  const handleNext = async ({ data, nextStep }: { data: Partial<CreateFormSchema\>; nextStep: CreateStep }) => {
    if (nextStep === CreateStep.SUBMIT) {
      // TODO: 서버 제출 처리 ...
      return;
    }

    updateFormData(data);
    updateCurrentStepId(nextStep);
  };

  const nextTo = (nextStep: CreateStep) => (data: Partial<CreateFormSchema\>) => handleNext({ data, nextStep });

  return (
        {isRequiredStep && <RequiredForm defaultValues={formDataForSubmit} onSuccess={nextTo(CreateStep.TEMPLATE)} />}
        {isTemplateStep && <TemplateForm defaultValues={formDataForSubmit} onSuccess={nextTo(CreateStep.TIMETABLE)} />}
        {isTimetableStep && <TimetableForm defaultValues={formDataForSubmit} onSuccess={nextTo(CreateStep.TIMTETABLE_DETAIL)} />}
        {isTimetableDetailStep && <TimetableDetailForm defaultValues={formDataForSubmit} onSuccess={nextTo(CreateStep.SUBMIT)} />}
  );
}

formDataForSubmit

const [formDataForSubmit, setFormDataForSubmit] = useState(initialFormData);

formDataForSubmit은 각 폼에서 반환해주는 폼 데이터를 받아주는 녀석이다. 요 녀석이 있어야 이전 폼으로 돌아갔을 때 초기 값을 지정해줄 수도 있고, 서버 제출 시에 이것을 기반으로 가공할 수도 있다.

currentStepId

  const [currentStepId, setCurrentStepId] = useState<CreateStep\>(CreateStep.REQUIRED);

  const isRequiredStep = currentStepId === CreateStep.REQUIRED;
  const isTemplateStep = currentStepId === CreateStep.TEMPLATE;
  const isTimetableStep = currentStepId === CreateStep.TIMETABLE;
  const isTimetableDetailStep = currentStepId === CreateStep.TIMTETABLE_DETAIL;

현재 단계에 대한 로직은 ID를 기반으로 작성했다. 굳이 현재 단계에 대한 모든 정보를 담을 필요는 없기 때문에! 이렇게 해놔야 중간에 추가하거나 제거를 하더라도 편하다.

onSuccess

{isRequiredStep && <RequiredForm {...} onSuccess={nextTo(CreateStep.TEMPLATE)} />}
{isTemplateStep && <TemplateForm {...} onSuccess={nextTo(CreateStep.TIMETABLE)} />}
{isTimetableStep && <TimetableForm {...} onSuccess={nextTo(CreateStep.TIMTETABLE_DETAIL)} />}
{isTimetableDetailStep && <TimetableDetailForm {...} onSuccess={nextTo(CreateStep.SUBMIT)} />}

또~ 볼 만한 것은 각 폼에서 성공 했을 때(onSuccess) 다음 단계 ID를 호출해준다는 것. 이렇게 index가 아닌 id로 처리하면 폼 목록이 시퀀셜하게 이루어져있지 않아도 된다.

nextTo()

const nextTo = (nextStep: CreateStep) => (data: Partial<CreateFormSchema\>) => handleNext({ data, nextStep });

참고로 onSuccess에서 넘어오는 인자는 각 폼에서 관리하는 폼 데이터이다. 해당 데이터를 공통으로 넘겨주고 있으니, 약간의 명시성을 위해 함수를 하나 작성해주었다. 이렇게 해주면 그냥 어디에 넘겨줄지 눈으로 쓰윽 읽혀서 좋다. (next/to/createstep/template)

필드셋 폼

각 필드셋을 관리하는 폼들은 크게 다음과 같은 구조로 작성했다.

Example Form

const formSchema = z.object({
  name: z.string()
  description: z.string().optional(),
});

export type RequiredRequest = Pick<CreateFormSchema, 'name' | 'description'>;
// export type RequiredRequest = z.infer<typeof formSchema\>;

type RequiredFormProp = {
  defaultValues: RequiredRequest;
  onSuccess: (data: RequiredRequest) => void;
};

export function RequiredForm({ defaultValues, onSuccess }: RequiredFormProp) {
  const form = useForm<RequiredRequest\>({
    resolver: zodResolver(formSchema),
    defaultValues,
  });

  const handleSubmitAfterValidation = (data: RequiredRequest) => {
    onSuccess(data);
  };

  return (
    <Form {...form}>
      <form className="w-full flex-1 flex" onSubmit={form.handleSubmit(handleSubmitAfterValidation)}>
        <fieldset className="flex flex-col border-none space-y-2 md:space-y-6 flex-1 w-full">
          <ClassNameField control={form.control} />
          <ClassDescriptionField control={form.control} />

          <SubmitButton>다음</SubmitButton>
        </fieldset>
      </form>
    </Form>
  );
}

많이 생략하긴 했는데, 이 정도의 구조가 기본 구조라고 생각하면 된다.

폼 Type

export type RequiredRequest = Pick<CreateFormSchema, 'name' | 'description'>;
// export type RequiredRequest = z.infer<typeof formSchema\>;

현재 제출하는 폼 스키마의 타입을 zod를 통해 가리키는 게 아니고 부모에서 정의한 제출 폼 스키마에서 Pick을 통해 가져오고 있다. 이렇게 해야지 올려줬을 때 Type에러가 안 난다. resolver는 그대로 formeSchema를 쓰고 있으니 유효성 검사는 그대로 진행된다!

유효성 통과 시 올려주기

  const handleSubmitAfterValidation = (data: RequiredRequest) => {
    onSuccess(data);
  };

처음에는 어렵게 생각했었는데, 그럴 필요도 없었던 게 RHF에서는 Form의 Submit 이벤트를 가로채서 유효성을 검사한 다음에 Redirect 없이 콜백 함수로 넘겨주기 때문에, 그냥 onSuccess에 data를 담아서 넘겨주기만 하면 된다.


막상 구현하고 보니 괜스레 겁을 먼저 먹었나 싶기도 하고! 사실 라우팅을 어떻게 분리할지에 대한 고민이 다였다. 재밌다 재밌어 ...

profile
FE개발자 가보자고🥳

0개의 댓글