TanStack Form으로 타입 안전한 React 폼 만들기
React로 폼을 만들어 본 적이 있다면 알 겁니다.
처음엔 useState()로 간단하게 시작하지만, 필드가 늘어나고 유효성 검사가 복잡해지면서 코드가 걷잡을 수 없이 커지는 경험 말이에요.
React Hook Form이 이 문제를 꽤 잘 해결해줬지만, TypeScript와 궁합이 아쉬운 경우가 종종 있었습니다.
TanStack Query로 서버 상태 관리를 혁신한 TanStack 팀이 이번엔 폼 관리에 도전장을 내밀었는데요. TanStack Form은 처음부터 TypeScript로 설계되어 필드 이름 하나까지 타입으로 잡아주고, 렌더 프롭 패턴으로 유연한 UI를 만들 수 있게 해줍니다.
이번 포스팅에서는 TanStack Form의 기본 사용법을 살펴보고, 간단한 폼부터 배열 필드와 스키마 검증까지 차근차근 만들어보겠습니다.
패키지 설치
TanStack Form은 React, Vue, Angular, Solid, Svelte 등 다양한 프레임워크를 지원하는데요. 이번 글에서는 React 어댑터를 사용하겠습니다.
bun add @tanstack/react-form
npm install @tanstack/react-form
TypeScript 프로젝트라면 별도의 타입 패키지 없이 바로 사용할 수 있습니다. 타입 정의가 라이브러리에 내장되어 있거든요.
useForm 훅으로 폼 만들기
TanStack Form의 시작점은 useForm 훅입니다.
defaultValues로 초기값을 설정하고, onSubmit으로 제출 로직을 정의합니다.
import { useForm } from "@tanstack/react-form";
function SignupForm() {
const form = useForm({
defaultValues: {
email: "",
password: "",
},
onSubmit: async ({ value }) => {
console.log("제출된 값:", value);
// value의 타입이 { email: string; password: string }으로 자동 추론됩니다
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
{/* 필드가 여기에 들어갑니다 */}
<button type="submit">가입</button>
</form>
);
}
onSubmit 콜백에서 value 파라미터의 타입이 defaultValues로부터 자동 추론됩니다.
별도의 인터페이스를 정의할 필요가 없으니 편리합니다.
한 가지 눈여겨볼 점은 <form> 태그의 onSubmit에서 e.stopPropagation()을 호출하는 부분입니다.
이벤트 버블링을 막아주는 건데, 폼이 중첩될 수 있는 상황에서 의도치 않은 부모 폼 제출을 방지하는 습관적인 패턴이라고 생각하시면 됩니다.
Field 컴포넌트로 필드 구성하기
개별 입력 필드는 form.Field 컴포넌트로 만듭니다.
TanStack Form은 렌더 프롭(render prop) 패턴을 사용하는데요, children 함수가 필드 API를 받아서 원하는 UI를 자유롭게 그릴 수 있습니다.
function SignupForm() {
const form = useForm({
defaultValues: {
email: "",
password: "",
},
onSubmit: async ({ value }) => {
console.log("제출된 값:", value);
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<div>
<form.Field
name="email"
children={(field) => (
<>
<label htmlFor={field.name}>이메일</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
</>
)}
/>
</div>
<div>
<form.Field
name="password"
children={(field) => (
<>
<label htmlFor={field.name}>비밀번호</label>
<input
id={field.name}
type="password"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
</>
)}
/>
</div>
<button type="submit">가입</button>
</form>
);
}
name 프롭에 "email"이라고 쓰면 TypeScript가 defaultValues에 email 필드가 있는지 자동으로 확인합니다.
오타를 내면 빨간 줄이 뜨니까 런타임 에러를 미리 잡을 수 있는 거죠.
field.state.value로 현재 값을 읽고 field.handleChange()로 값을 바꿉니다.
field.handleBlur()는 포커스가 빠져나갈 때 호출해서 터치 여부를 추적하는 용도입니다.
비제어 컴포넌트 방식인 React Hook Form과 달리 TanStack Form은 제어 컴포넌트 방식을 사용합니다.
값의 흐름이 명시적이라서 디버깅하기가 수월합니다.
동기 유효성 검사
폼에서 가장 신경 쓰이는 부분이 유효성 검사인데요. TanStack Form은 필드별로 검증 시점과 로직을 세밀하게 지정할 수 있습니다.
validators 프롭에 onChange, onBlur 같은 이벤트별 검증 함수를 넣으면 됩니다.
<form.Field
name="email"
validators={{
onChange: ({ value }) => {
if (!value) return "이메일을 입력해주세요";
if (!/\S+@\S+\.\S+/.test(value)) return "올바른 이메일 형식이 아닙니다";
return undefined; // 에러 없음
},
}}
children={(field) => (
<>
<label htmlFor={field.name}>이메일</label>
<input
id={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.isTouched && !field.state.meta.isValid && (
<em>{field.state.meta.errors.join(", ")}</em>
)}
</>
)}
/>
검증 함수가 문자열을 반환하면 에러 메시지로 처리되고, undefined를 반환하면 검증 통과입니다.
field.state.meta.errors 배열에서 에러 메시지를 꺼내 화면에 보여줄 수 있습니다.
isTouched를 같이 확인하는 이유는 사용자가 아직 건드리지 않은 필드에 에러를 미리 보여주면 사용 경험이 안 좋아지기 때문입니다.
비동기 유효성 검사
이메일 중복 확인처럼 서버에 요청해야 하는 검증은 onChangeAsync로 처리합니다.
onChangeAsyncDebounceMs를 함께 설정하면 타이핑할 때마다 요청이 날아가는 걸 막을 수 있습니다.
<form.Field
name="email"
validators={{
onChange: ({ value }) => {
if (!value) return "이메일을 입력해주세요";
if (!/\S+@\S+\.\S+/.test(value)) return "올바른 이메일 형식이 아닙니다";
return undefined;
},
onChangeAsyncDebounceMs: 500,
onChangeAsync: async ({ value }) => {
// 서버에 이메일 중복 확인 요청
const response = await fetch(`/api/check-email?email=${value}`);
const { exists } = await response.json();
if (exists) return "이미 사용 중인 이메일입니다";
return undefined;
},
}}
children={(field) => (
<>
<label htmlFor={field.name}>이메일</label>
<input
id={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.isValidating && <span>확인 중...</span>}
{field.state.meta.isTouched && !field.state.meta.isValid && (
<em>{field.state.meta.errors.join(", ")}</em>
)}
</>
)}
/>
동기 검증(onChange)을 먼저 통과해야 비동기 검증(onChangeAsync)이 실행됩니다.
이메일 형식이 맞지 않으면 서버 요청 자체를 하지 않으니 불필요한 API 호출을 줄일 수 있습니다.
field.state.meta.isValidating이 true일 때 “확인 중…” 같은 로딩 표시를 해주면 사용자가 검증이 진행 중이라는 걸 알 수 있겠죠.
에러 표시 컴포넌트
에러 표시 코드가 필드마다 반복되면 번거로우니까 작은 헬퍼 컴포넌트를 하나 만들어두면 편합니다.
import type { AnyFieldApi } from "@tanstack/react-form";
function FieldInfo({ field }: { field: AnyFieldApi }) {
return (
<>
{field.state.meta.isTouched && !field.state.meta.isValid ? (
<em>{field.state.meta.errors.join(", ")}</em>
) : null}
{field.state.meta.isValidating ? "확인 중..." : null}
</>
);
}
이제 각 필드 아래에 <FieldInfo field={field} />만 넣으면 됩니다.
TanStack Form 공식 예제에서도 이 패턴을 사용하고 있습니다.
제출 상태 구독하기
폼이 제출 중일 때 버튼을 비활성화하거나 로딩 표시를 하고 싶을 때가 있는데요.
form.Subscribe를 사용하면 폼 상태 중 필요한 부분만 구독할 수 있습니다.
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
<>
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? "제출 중..." : "가입"}
</button>
<button type="button" onClick={() => form.reset()}>
초기화
</button>
</>
)}
/>
selector로 필요한 상태만 골라 구독하기 때문에 다른 필드 값이 바뀔 때 버튼이 불필요하게 리렌더링되지 않습니다.
canSubmit은 모든 필드의 검증이 통과되었고 현재 제출 중이 아닐 때 true가 됩니다.
배열 필드 다루기
회원가입 폼에서 여러 명의 팀원 정보를 입력받거나, 주문서에서 상품을 추가/삭제하는 것처럼 동적 목록이 필요한 경우가 있습니다.
TanStack Form은 mode="array" 옵션으로 배열 필드를 자연스럽게 지원합니다.
import { useForm } from "@tanstack/react-form";
interface Person {
name: string;
age: number;
}
function TeamForm() {
const form = useForm({
defaultValues: {
people: [] as Person[],
},
onSubmit: async ({ value }) => {
console.log("팀원 목록:", value.people);
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<form.Field name="people" mode="array">
{(field) => (
<div>
{field.state.value.map((_, i) => (
<div key={i}>
<form.Field name={`people[${i}].name`}>
{(subField) => (
<label>
이름
<input
value={subField.state.value}
onChange={(e) => subField.handleChange(e.target.value)}
/>
</label>
)}
</form.Field>
<form.Field name={`people[${i}].age`}>
{(subField) => (
<label>
나이
<input
type="number"
value={subField.state.value}
onChange={(e) =>
subField.handleChange(Number(e.target.value))
}
/>
</label>
)}
</form.Field>
<button type="button" onClick={() => field.removeValue(i)}>
삭제
</button>
</div>
))}
<button
type="button"
onClick={() => field.pushValue({ name: "", age: 0 })}
>
팀원 추가
</button>
</div>
)}
</form.Field>
<button type="submit">제출</button>
</form>
);
}
배열 필드의 각 항목은 people[${i}].name 형태의 중첩 경로로 접근합니다.
field.pushValue()로 새 항목을 추가하고, field.removeValue(i)로 특정 인덱스의 항목을 제거할 수 있습니다.
swapValues()나 moveValue(), insertValue() 같은 메서드도 제공되어서 항목 순서를 바꾸거나 특정 위치에 끼워넣을 수도 있습니다.
드래그 앤 드롭으로 순서를 바꾸는 UI를 만들 때 유용하겠죠.
스키마 검증과 Standard Schema
필드별로 검증 함수를 일일이 작성하는 게 번거로울 수 있습니다. 특히 폼 전체에 걸친 복잡한 검증 규칙이 있다면 Zod나 Valibot 같은 스키마 라이브러리를 쓰는 게 훨씬 깔끔합니다.
TanStack Form은 Standard Schema 인터페이스를 지원하기 때문에 Zod, Valibot, ArkType 등 다양한 스키마 라이브러리를 플러그인 없이 바로 연결할 수 있습니다.
Zod를 예로 들어볼게요.
bun add zod
npm install zod
import { useForm } from "@tanstack/react-form";
import { z } from "zod";
const signupSchema = z.object({
email: z
.string()
.min(1, "이메일을 입력해주세요")
.email("올바른 이메일 형식이 아닙니다"),
password: z
.string()
.min(8, "비밀번호는 8자 이상이어야 합니다")
.regex(/[A-Z]/, "대문자를 하나 이상 포함해야 합니다"),
});
function SignupForm() {
const form = useForm({
defaultValues: {
email: "",
password: "",
},
validators: {
onChange: signupSchema,
},
onSubmit: async ({ value }) => {
console.log("제출된 값:", value);
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<div>
<form.Field
name="email"
children={(field) => (
<>
<label htmlFor={field.name}>이메일</label>
<input
id={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.isTouched && !field.state.meta.isValid && (
<em>
{field.state.meta.errors.map((err) => err.message).join(", ")}
</em>
)}
</>
)}
/>
</div>
<div>
<form.Field
name="password"
children={(field) => (
<>
<label htmlFor={field.name}>비밀번호</label>
<input
id={field.name}
type="password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.isTouched && !field.state.meta.isValid && (
<em>
{field.state.meta.errors.map((err) => err.message).join(", ")}
</em>
)}
</>
)}
/>
</div>
<button type="submit">가입</button>
</form>
);
}
useForm의 validators.onChange에 Zod 스키마를 통째로 넘기면 폼 전체가 해당 스키마로 검증됩니다.
필드별 검증 함수를 따로 작성할 필요가 없고, 스키마 하나로 타입 정의와 검증 규칙을 모두 관리할 수 있어서 유지보수가 편해집니다.
한 가지 주의할 점은 Standard Schema를 사용할 때 에러 메시지의 형태가 조금 다르다는 겁니다.
인라인 검증에서는 field.state.meta.errors가 문자열 배열인데, 스키마 검증에서는 { message: string } 형태의 객체 배열이 됩니다.
그래서 .map((err) => err.message)로 메시지를 꺼내야 합니다.
React Hook Form과 비교
React 생태계에서 가장 널리 쓰이는 폼 라이브러리인 React Hook Form과 비교해보면 두 라이브러리의 설계 철학 차이가 뚜렷합니다.
우선 React Hook Form은 비제어 컴포넌트를 기반으로 합니다.
register() 함수가 DOM에 직접 접근해서 성능을 끌어올리는 방식이죠.
반면 TanStack Form은 제어 컴포넌트 방식으로, value와 onChange를 명시적으로 연결합니다.
타입 안전성 측면에서는 TanStack Form이 한 발 앞서 있습니다.
defaultValues에서 추론한 타입이 Field 컴포넌트의 name 프롭까지 흘러가기 때문에, 잘못된 필드 이름을 쓰면 컴파일 타임에 잡힙니다.
React Hook Form도 TypeScript를 지원하지만 register("emial")처럼 오타를 낸 경우 런타임까지 가야 발견되는 경우가 있었습니다.
프레임워크 지원 범위도 다릅니다. React Hook Form은 이름 그대로 React 전용인데, TanStack Form은 Vue, Angular, Solid, Svelte까지 지원합니다. TanStack Query나 TanStack Router, TanStack Start와 같은 TanStack 생태계를 이미 사용하고 있다면 일관된 패턴으로 개발할 수 있다는 이점도 있습니다.
다만 React Hook Form은 오랫동안 넓은 사용자 기반을 쌓아왔고, 서드파티 UI 라이브러리와의 통합이 잘 되어 있습니다. TanStack Form은 상대적으로 새로운 라이브러리라 커뮤니티 리소스가 적은 편이에요. 기존 프로젝트에서 React Hook Form을 잘 쓰고 있다면 굳이 마이그레이션할 필요는 없고, 새 프로젝트를 시작하거나 타입 안전성이 중요한 상황에서 TanStack Form을 고려해보면 좋겠습니다.
마치며
TanStack Form은 “타입 안전성”과 “프레임워크 독립성”이라는 두 가지 목표를 잘 달성한 폼 라이브러리입니다.
useForm → Field → validators로 이어지는 흐름만 익히면 대부분의 폼을 깔끔하게 만들 수 있습니다.
배열 필드나 스키마 검증 같은 고급 기능도 자연스럽게 확장되니까, 간단한 로그인 폼부터 복잡한 다단계 설문까지 하나의 라이브러리로 커버할 수 있습니다. React가 아닌 다른 프레임워크로 넘어가더라도 같은 멘탈 모델을 유지할 수 있다는 것도 큰 장점이고요.
더 자세한 내용은 TanStack Form 공식 문서를 참고하세요.
This work is licensed under
CC BY 4.0