홈으로 돌아가기

Zod를 사용한 Next.js 폼을 위한 typed-form-actions

typed-form-actions 라이브러리는 Next.js App Router에서 타입화된 폼 생성을 간소화합니다. FormData 파싱, Zod 검증 및 React 상태 관리를 자동화하며 네이티브 HTML 폼을 잃지 않습니다. 최소 클라이언트 JS로 server-first 접근에 적합합니다.

typed-form-actions: 네이티브 타입화 Next.js 폼
Advertisement 728x90

Next.js에서 네이티브 폼을 위한 타입 안전 폼 액션: typed-form-actions

Next.js 개발자들은 폼 처리 시 반복적인 코드에 직면하는 경우가 많습니다. FormData를 파싱하고, 중첩 구조를 만들며, Zod로 검증하고, UI용 오류를 변환하며, useActionState를 관리해야 하는데요. 이 과정은 지루하고 실수하기 쉬운 작업입니다.

typed-form-actions 라이브러리는 이러한 작업들을 자동화하면서도, 네이티브 HTML 폼과 서버 우선 접근 방식을 유지합니다.

핵심 아이디어는 FormData, Zod, useActionState를 타입 안전 계층으로 통합하는 것입니다. 폼은 여전히 표준 상태를 유지하지만, 빌러플레이트 없이 자동 타이핑, 오류 처리, 대기 상태를 제공합니다.

Google AdInline article slot

추상화 없이 겪는 고통

일반적인 Zod와 함께 서버 액션 흐름에서는 다음과 같은 단계를 거칩니다:

  • 요청에서 FormData를 추출합니다.
  • 필드를 수동으로 객체로 파싱합니다.
  • 스키마 기반으로 검증합니다.
  • Zod 오류를 Record<string, string[]> 형식으로 변환합니다.
  • values, fieldErrors, formError를 포함한 표준 상태를 반환합니다.
  • 클라이언트 측에서는 defaultValue, aria-invalid를 수동으로 바인딩하고 오류를 처리해야 합니다.

보통의 코드 예시:

"use server";
import { z } from "zod";

const contactSchema = z.object({
  name: z.string().min(2, "이름이 너무 짧습니다."),
  email: z.string().email("올바른 이메일 주소를 입력하세요."),
  message: z.string().min(10, "메시지가 너무 짧습니다."),
});

export async function sendMessage(prevState: any, formData: FormData) {
  const rawValues = {
    name: formData.get("name"),
    email: formData.get("email"),
    message: formData.get("message"),
  };

  const parsed = contactSchema.safeParse(rawValues);
  if (!parsed.success) {
    return {
      status: "error",
      values: rawValues,
      data: null,
      formError: "하이라이트된 필드를 수정하고 다시 시도해 주세요.",
      fieldErrors: parsed.error.flatten().fieldErrors,
      submittedAt: Date.now(),
    };
  }
  // ...
}

이 패턴은 모든 폼에서 반복됩니다. 배열, 체크박스, 또는 중첩 필드(profile.name, links[0].href)가 포함되면 코드는 급격히 복잡해집니다.

Google AdInline article slot

라이브러리 API

이 라이브러리는 두 가지 핵심 함수 기반으로 설계되었습니다.

createFormAction

Zod 스키마와 핸들러를 받아 즉시 사용 가능한 서버 액션을 반환합니다:

import { createFormAction } from "typed-form-actions";
import { z } from "zod";

const newsletterSchema = z.object({
  email: z.string().email("올바른 이메일 주소를 입력하세요."),
  topics: z.array(z.string()).min(1, "최소 한 가지 주제를 선택하세요."),
  marketing: z
    .preprocess((value) => value === "on", z.boolean())
    .default(false),
});

export const subscribeAction = createFormAction({
  schema: newsletterSchema,
  async handler(values) {
    return {
      message: `가입 완료: ${values.email}`,
      topicsCount: values.topics.length,
    };
  },
});

useActionForm

useActionState 위에 구축된 클라이언트 측 훅으로, 간결한 API를 제공합니다:

Google AdInline article slot
"use client";

import { useActionForm } from "typed-form-actions/react";

import { subscribeAction } from "./actions";

export function NewsletterForm() {
  const form = useActionForm(subscribeAction);

  return (
    <form action={form.action}>
      <label>
        이메일
        <input {...form.getInputProps("email", { id: "email", type: "email" })} />
        {form.getFieldError("email") ? (
          <p id={form.getFieldErrorId("email")}>
            {form.getFieldError("email")}
          </p>
        ) : null}
      </label>

      <fieldset>
        <legend>주제</legend>
        <label>
          <input
            {...form.getInputProps("topics", {
              type: "checkbox",
              value: "react",
            })}
          />
          React
        </label>
        {/* 기타 체크박스도 유사하게 추가 */}
      </fieldset>

      {form.formError ? <p>{form.formError}</p> : null}

      <button disabled={form.isPending} type="submit">
        {form.isPending ? "저장 중..." : "구독하기"}
      </button>
    </form>
  );
}

내부 동작 원리

FormData 파싱

라이브러리는 평탄한 FormData를 중첩 객체로 변환합니다:

입력:

{
  "profile.name": "Ada",
  "tags[]": ["react", "next"],
  "links[0].href": "/docs"
}

출력:

{
  profile: { name: "Ada" },
  tags: ["react", "next"],
  links: [{ href: "/docs" }]
}

profile.name, links[0].href와 같은 경로 및 반복 필드를 지원합니다.

통합된 FormActionState

type FormActionState<TValues, TResult> = {
  status: "idle" | "success" | "error";
  values: Partial<TValues>;
  data: TResult | null;
  fieldErrors: Record<string, string[]>;
  formError: string | null;
  submittedAt: number | null;
};

useActionForm은 자동으로 다음을 생성합니다:

  • isPending
  • getFieldError(field)
  • getFieldErrorId(field)
  • defaultValue / defaultChecked
  • aria-invalid / aria-describedby

react-hook-form과 비교

| 요소 | typed-form-actions | react-hook-form |

|------|-------------------|-----------------|

| 네이티브 폼 | ✅ | ❌ (클라이언트 사이드) |

| 서버 액션 | ✅ | 어댑터를 통해 가능 |

| 클라이언트 JS | 최소 | 상당함 |

| Zod 통합 | 내장 | 리졸버를 통해 |

| 복잡한 폼 | 기본 지원 | 완전한 지원 |

이 라이브러리는 RHF와 직접 경쟁하지 않습니다. 오히려 Next.js App Router 환경에서 서버 우선 폼 문제를 해결합니다.

현재 기능

  • FormData, URLSearchParams, 일반 객체 파싱
  • 중첩 구조 및 배열 지원
  • Zod 검증
  • 예측 가능한 FormActionState
  • 오류 및 대기 상태용 React API
  • 엔드투엔드 타입 안전성

제한 사항

  • react-hook-form 어댑터 미지원
  • 복잡한 객체 배열은 개선 필요
  • 파일 및 특수 입력 요소 미지원
  • API 변경 가능성 있음

가장 중요한 점

  • 빌러플레이트 자동화: 파싱, 검증, 오류 처리 — 수동 코드 없음
  • 네이티브 폼: 표준 <form> 요소, 비제어 컴포넌트 없음
  • 타입 안전성: 스키마에서 UI까지 전반적인 타입 보장
  • 서버 우선: 최소한의 클라이언트 JS, 플랫폼 최적화 강점
  • 예측 가능성: 모든 폼에서 일관된 FormActionState

— Editorial Team

Advertisement 728x90

다음 읽기