돌아가기

TanStack Form v1 출시 기념 둘러보기

TanStack Form v1 출시 기념 둘러보기

React에서 폼(Form)을 다루는 건 늘 숙제 같습니다. React Hook Form이 사실상 표준처럼 자리 잡으며 많은 부분이 편해지긴 했지만, 여전히 복잡한 요구사항 앞에서는 아쉬운 지점들이 있었거든요.

TanStack Query 문서를 보다가 우연히 TanStack Form v1 출시 소식을 접했습니다. Tanner Linsley가 주도하는 팀이 2년 동안 만들었다고 하니, 단순한 호기심을 넘어 실무적으로 쓸만한지 궁금해졌습니다. 이 라이브러리가 React 폼 개발의 가려운 곳을 긁어줄 수 있을지, 코드를 뜯어보며 살펴봤습니다.

1. React 폼, 왜 아직도 까다로울까?

생 자바스크립트로 폼 로직을 짜는 건 고통에 가깝습니다. 그래서 Formik이 한때 정석이었고, Hooks 도입 이후엔 React Hook Form이 렌더링 최적화를 무기로 대통합을 이뤘죠.

하지만 웹 개발 환경이 또 변했습니다. 서버 컴포넌트(RSC)가 도입되었고, TypeScript의 타입 추론 수준은 비약적으로 높아졌습니다. 기존 라이브러리들도 좋지만, '타입 안전성'이나 '서버와의 통합' 측면에서 조금씩 삐걱거리는 느낌을 받을 때가 있습니다. TanStack Form은 바로 이 지점을 파고들었습니다.

2. 이미 RHF가 있는데 굳이?

TypeScript 지원이라는 말은 흔하지만, 그 깊이가 다릅니다. 기존 라이브러리들이 JS 기반 위에 TS 타입을 덧붙인 느낌이라면, TanStack Form은 타입 추론을 핵심 기능으로 설계된 느낌입니다.

필드명 오타나 타입 불일치를 런타임이 아닌 코드를 짜는 순간에 잡아줍니다. 단순히 .d.ts 파일을 제공하는 수준을 넘어, 라이브러리 설계 자체가 TS의 최신 기능을 전제로 하고 있다는 점이 흥미로웠습니다.

다른 라이브러리들과 비교해보면 차이가 명확합니다.

기능TanStack FormFormikReact Hook Form
일급 TypeScript 지원
완전 추론 TypeScript
헤드리스 UI
프레임워크 독립적🔴
세분화된 반응성
비동기 유효성 검사
비동기 검사 디바운스
SSR 통합🔴🔴

Github 스타 수는 아직 RHF가 압도적이지만, 기능 명세표를 보면 SSR 지원이나 비동기 검증 디바운스 등, 최근 개발자들이 가려워하는 부분을 정확히 짚고 있습니다.

3. 구조적 특징: Headless & Performance

가장 큰 특징은 Headless입니다. UI와 로직이 완전히 분리되어 있어 React뿐만 아니라 Vue, Angular 등 다양한 프레임워크에서도 동일한 로직을 사용할 수 있습니다.

성능 면에서는 세밀한 반응성을 강조합니다. 폼 상태가 변할 때 폼 전체가 리렌더링되는 것이 아니라, 해당 변경 사항을 구독하고 있는 필드만 정확하게 업데이트됩니다. 필드가 50개, 100개 넘어가는 대시보드 형태의 폼을 다룰 때 꽤 유의미한 최적화가 될 것 같습니다.

4. 철학 파헤치기

4-1. API의 일관성

RHF를 쓰다 보면 register를 쓸지 Controller를 쓸지 고민하게 되는 순간이 옵니다. TanStack Form은 이를 통합된 API로 제공하여, 도구 사용법보다는 비즈니스 로직 자체에 집중하게 해줍니다.

4-2. 비동기 검증의 내재화

개인적으로 가장 반가웠던 부분입니다. 회원가입 시 이메일 중복 확인 같은 기능을 구현할 때, RHF에서는 onChange 핸들러를 커스텀하고 디바운싱 로직을 따로 짜야 했습니다.

TanStack Form은 비동기 검증과 디바운싱, 취소 기능이 내장되어 있습니다. blur 이벤트 발생 시 검사를 중단하거나, 타이핑 중에는 검사를 지연시키는 로직을 별도로 구현할 필요가 없습니다.

4-3. 제네릭 없는 타입 안전성

보통은 인터페이스를 먼저 정의하고 제네릭으로 주입하는 방식을 씁니다.

// RHF 스타일: 제네릭 타입 파라미터 사용
interface MyForm {
  name: string
  age: number
}
const form = useForm<MyForm>()

이 방식은 런타임 값의 존재를 보장하지 않습니다. 반면, TanStack Form은 Value에서 타입을 추론하는 방식을 제안합니다.

// TanStack Form 스타일: 런타임 값에서 타입 추론
const form = useForm({
  defaultValues: {
    name: 'Bill Luo',
    age: 24,
  } as MyForm,
})

defaultValues를 기반으로 타입이 자동 추론되므로, 타입 정의와 실제 값 사이의 괴리를 줄여줍니다. 실제 써보면 as MyForm을 통해 업캐스팅하는 방식이 생각보다 꽤 안전하고 직관적입니다.

5. 실제 사용 예시

코드는 확실히 RHF보다 조금 더 명시적이고, 설정할 게 있어 보입니다. 하지만 그만큼 타입 안전성이 강력합니다.

1. 기본 설정 (Context 활용) createFormHook을 사용해 컨텍스트를 주입하면, 앱 전체에서 보일러플레이트를 줄일 수 있습니다.

import { createFormHook, createFormHookContexts } from '@tanstack/react-form'
import { z } from 'zod'

// 1. 컨텍스트 생성
const { fieldContext, formContext } = createFormHookContexts()

// 2. 컴포넌트 바인딩 (한 번만 설정하면 됨)
const { useAppForm } = createFormHook({
  fieldComponents: { TextField, NumberField }, // 미리 만들어둔 UI 컴포넌트
  formComponents: { SubmitButton },
  fieldContext,
  formContext,
})

const PeoplePage = () => {
  const form = useAppForm({
    defaultValues: {
      username: '',
      age: 0,
    },
    validators: {
      // Zod 스키마와 바로 연동됩니다
      onChange: z.object({
        username: z.string(),
        age: z.number().min(13),
      }),
    },
    onSubmit: ({ value }) => {
      alert(JSON.stringify(value, null, 2))
    },
  })

  return (
    <form onSubmit={(e) => { e.preventDefault(); form.handleSubmit() }}>
      {/* name 속성에 오타가 나면 바로 빨간 줄이 그어집니다 */}
      <form.AppField
        name="username"
        children={(field) => <field.TextField label="Full Name" />}
      />
      <form.AppField
        name="age"
        children={(field) => <field.NumberField label="Age" />}
      />
      <form.AppForm>
        <form.SubmitButton />
      </form.AppForm>
    </form>
  )
}

2. 간단하게 사용하기 물론 복잡한 설정 없이 가볍게 쓸 수도 있습니다.

const PeoplePage = () => {
  const form = useForm({
    defaultValues: { username: '', age: 0 },
    onSubmit: ({ value }) => { /* ... */ },
  })

  return (
    <form.Field
      name="age"
      validators={{
        // 함수형 검증도 직관적입니다
        onChange: ({ value }) => value > 13 ? undefined : 'Must be 13 or older',
      }}
      children={(field) => (
        <>
          <input
            name={field.name}
            value={field.state.value}
            onBlur={field.handleBlur}
            onChange={(e) => field.handleChange(e.target.valueAsNumber)}
          />
          {/* 에러 메시지 처리 */}
          {field.state.meta.errors.length ? (
            <em>{field.state.meta.errors.join(',')}</em>
          ) : null}
        </>
      )}
    />
  )
}

6. 놓치면 아쉬운 기능들

6-1. 대규모 폼 관리

실무에서 필드가 수십 개 넘어가면 파일 하나가 너무 비대해집니다. TanStack Form은 withForm HOC를 제공해 폼을 논리적인 단위로 쪼개서 관리할 수 있게 해줍니다.

// 자식 폼 컴포넌트 분리
const ChildForm = withForm({
  defaultValues: { firstName: 'John', lastName: 'Doe' },
  render: function Render({ form }) {
    return (
      <div>
        <form.AppField
          name="firstName"
          children={(field) => <field.TextField label="First Name" />}
        />
      </div>
    )
  },
})

// 메인 앱에서 사용
function App() {
  const form = useAppForm({ /* ... */ })
  // 부모 폼 인스턴스를 자식에게 전달
  return <ChildForm form={form} />
}

여기에 React.lazy를 섞으면 트리 쉐이킹과 지연 로딩까지 챙길 수 있습니다. 유지보수 측면에서 정말 큰 장점입니다.

6-2. 서버 컴포넌트(RSC) 통합

Next.js App Router나 Remix를 쓴다면 이 기능이 킬러 포인트가 될 것 같습니다. 프레임워크별로 전용 패키지를 제공합니다.

// Next.js용
import { formOptions, createServerValidate } from "@tanstack/react-form/nextjs";
// Remix용
import { formOptions, createServerValidate } from "@tanstack/react-form/remix";

JS가 로드되기 전에도 폼이 동작하는 '진보적 향상(Progressive Enhancement)'을 고려했고, 서버 액션(useActionState)과 클라이언트 폼 상태(mergeForm)를 매끄럽게 연결해줍니다.

7. 마무리

TanStack Form을 훑어보며 느낀 점을 정리하자면:

  1. 타입 안전성: 오타나 타입 실수를 잡는 경험이 확실히 쾌적합니다.
  2. 비동기 처리: 중복 체크 같은 실무적인 요구사항이 기본 내장되어 편리합니다.
  3. 서버 통합: 최신 React 프레임워크 트렌드를 잘 따르고 있습니다.
  4. 초기 설정: RHF보다는 처음에 작성해야 할 코드가 조금 더 있는 편입니다.

당장 잘 돌아가는 RHF 코드를 엎을 필요는 없겠지만, Next.js로 시작하는 새 프로젝트가 있다면 도입을 진지하게 고려해 볼 만합니다. 특히 폼이 복잡하고 서버와의 인터랙션이 많다면 충분히 매력적인 선택지가 될 것 같습니다.

댓글을 불러오는 중...

ikki-kki.dev - 2025