글루 코드 없이 타입 안전한 React 폼
I’m happy to translate the article for you, but I’ll need the full text you’d like translated. Could you please paste the content (or the portion you want translated) here? I’ll keep the source line, formatting, markdown, and any code blocks exactly as they are while translating the surrounding text into Korean.
모든 React 폼 라이브러리는 동일한 퍼즐을 조립하게 만든다
검증 라이브러리, 어댑터 패키지, 별도의 TypeScript 인터페이스, 그리고 폼 훅 자체. 각각은 독립적으로는 훌륭합니다. 마찰은 이들을 연결하는 과정에 있습니다.
스키마, 타입, 그리고 폼 훅을 처음부터 함께 설계했을 때 어떤 일이 일어나는지 보고 싶었습니다. 어댑터는 없습니다. 리졸버도 없습니다. 타입이 스키마 정의에서 필드‑prop 자동완성까지 흐르는 하나의 의존성 체인만 존재합니다.
제가 만든 것이 바로 이것이며, 이전에 사용하던 방식과 어떻게 다른지 비교해 보겠습니다.
일반적인 설정
다음은 전형적인 React Hook Form + Zod 등록 폼입니다. 좋은 코드이며, 저는 수년간 이런 형태의 폼을 작성해 왔습니다:
// 1. Install three packages
// npm add react-hook-form zod @hookform/resolvers/zod
// 2. Define a Zod schema
import { z } from 'zod'
const schema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
age: z.coerce.number().min(18, 'Must be 18+'),
bio: z.string().optional(),
})
// 3. Extract the type
type FormValues = z.infer
// 4. Wire everything together with the resolver adapter
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
function RegistrationForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({
resolver: zodResolver(schema),
defaultValues: { name: '', email: '', age: 0, bio: '' },
})
const onSubmit = async (values: FormValues) => {
await api.register(values)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input {...register('age')} />
{errors.age && <span>{errors.age.message}</span>}
<button type="submit" disabled={isSubmitting}>Submit</button>
</form>
)
}
세 개의 패키지. Zod의 출력 형식을 React Hook Form의 오류 형식에 연결해 주는 resolver 어댑터. 별도의 타입 추출 단계. 작동하고 RHF는 잘 만들어진 라이브러리이지만, 모든 폼은 이와 같은 절차로 시작합니다.
하나의 스키마, 별도 연결 없이
다음은 스키마, 타입 추론, 그리고 폼 훅을 모두 결합한 라이브러리를 사용한 동일한 폼 예시입니다:
// 1. 두 패키지(폼 훅과 그 검증 의존성)를 설치합니다
// npm add @railway-ts/use-form @railway-ts/pipelines
// 2. 스키마를 정의합니다 — 이것이 바로 검증 로직이자 타입 소스입니다
import { useForm } from '@railway-ts/use-form'
import {
object,
required,
optional,
chain,
string,
nonEmpty,
email,
parseNumber,
min,
type InferSchemaType,
} from '@railway-ts/pipelines/schema'
const schema = object({
name: required(chain(string(), nonEmpty('Name is required'))),
email: required(chain(string(), nonEmpty('Email is required'), email('Invalid email'))),
age: required(chain(parseNumber(), min(18, 'Must be 18+'))),
bio: optional(string()),
})
// 3. 여기까지! 타입이 자동으로 추론되고, 훅은 스키마를 바로 사용합니다
type FormValues = InferSchemaType
// { name: string; email: string; age: number; bio?: string }
function RegistrationForm() {
const form = useForm(schema, {
initialValues: { name: '', email: '', age: 0, bio: '' },
onSubmit: async (values) => {
// `values`는 FormValues 타입이며, 유효함이 보장됩니다
await api.register(values)
},
})
return (
<form onSubmit={form.handleSubmit}>
<input {...form.getFieldProps('name')} />
{form.touched.name && form.errors.name && <span>{form.errors.name}</span>}
<input {...form.getFieldProps('email')} />
{form.touched.email && form.errors.email && <span>{form.errors.email}</span>}
<input {...form.getFieldProps('age')} />
{form.touched.age && form.errors.age && <span>{form.errors.age}</span>}
<button type="submit">Submit</button>
</form>
)
}
리졸버도, 어댑터도 없습니다. 스키마를 useForm에 바로 전달하면 됩니다. 타입은 자동으로 흐릅니다.
form.getFieldProps(' 를 입력하면 에디터가 name, email, age, bio 를 자동 완성해 줍니다. form.getFieldProps('nme') 와 같이 잘못 입력하면 TypeScript가 컴파일 단계에서 오류를 잡아냅니다. form.errors.email 은 타입이 지정되어 있고, form.values.age 는 숫자 타입입니다. 모두 동일한 스키마 정의에서 비롯됩니다.
텍스트 입력만이 아닙니다
이 훅은 네이티브 HTML 폼 요소에 대한 바인딩을 제공합니다:
{/* Text, email, password, textarea */}
<input {...form.getFieldProps('email')} type="email" />
{/* Select */}
<select {...form.getFieldProps('country')}>
<option value="">Choose…</option>
<option value="US">United States</option>
<option value="CA">Canada</option>
</select>
{/* Checkbox (boolean) */}
<input {...form.getFieldProps('agreeTerms')} type="checkbox" />
{/* Switch (toggle — styled checkbox) */}
<input {...form.getFieldProps('notifications')} type="checkbox" />
{/* Radio group */}
<input {...form.getFieldProps('plan')} type="radio" value="free" /> Free
<input {...form.getFieldProps('plan')} type="radio" value="pro" /> Pro
{/* Checkbox group (array of values) */}
<input {...form.getFieldProps('interests[0]')} type="checkbox" value="sports" /> Sports
<input {...form.getFieldProps('interests[1]')} type="checkbox" value="music" /> Music
{/* File input */}
<input {...form.getFieldProps('avatar')} type="file" />
{/* Range slider */}
<input {...form.getFieldProps('volume')} type="range" min="0" max="100" />
각 헬퍼는 해당 요소 유형에 맞는 올바른 id, name, value/checked, 그리고 onChange 속성을 반환하여 API를 인체공학적이고 타입‑안전하게 유지합니다.
중첩 객체 — 점만 사용하세요
특별한 API 없이 중첩 데이터를 처리할 수 있습니다. 점 표기법은 어디서든 작동합니다:
import {
object,
required,
chain,
string,
nonEmpty,
} from '@railway-ts/pipelines/schema'
const profileSchema = object({
name: required(string()),
address: required(
object({
street: required(string()),
city: required(
chain(string(), nonEmpty('City is required'))
),
zip: required(string()),
})
),
})
// In the form:
{form.touched['address.city'] && form.errors['address.city'] && (
<span>{form.errors['address.city']}</span>
)}
폼에서:
자동 완성은 중첩을 통해 작동합니다 — address.를 입력하면 편집기가 street, city, zip을 제안합니다.
동적 배열
arrayHelpers는 리스트에 대한 타입이 지정된 변이 메서드를 제공합니다:
const { values, push, remove, swap, getFieldProps } =
form.arrayHelpers('contacts')
{values.map((contact, i) => (
<div key={i}>
<input {...getFieldProps(`contacts[${i}].name`)} />
<input {...getFieldProps(`contacts[${i}].email`)} />
<button type="button" onClick={() => remove(i)}>Remove</button>
</div>
))}
<button type="button" onClick={() => push({ name: '', email: '' })}>
Add Contact
</button>
push, remove, insert, swap, move, replace — 모두 타입‑안전하며 검증을 자동으로 업데이트합니다.
Validation Modes
Not every form wants the same validation timing:
// Validate on every keystroke and blur (default)
useForm(schema, { initialValues, validationMode: 'live' })
// Validate only when a field loses focus
useForm(schema, { initialValues, validationMode: 'blur' })
// Validate once on mount — good for editing existing records
useForm(schema, { initialValues, validationMode: 'mount' })
// Don't validate until submit
useForm(schema, { initialValues, validationMode: 'submit' })
서버 오류
제출 후, API가 필드 수준 오류를 반환할 수 있습니다. 오류를 설정하면 사용자가 해당 필드를 편집할 때 자동으로 사라집니다:
const form = useForm(schema, {
initialValues: { email: '', username: '' },
onSubmit: async (values) => {
const response = await api.register(values)
if (!response.ok) {
form.setServerErrors({
email: 'Email already exists',
username: 'Username taken',
})
return
}
router.push('/dashboard')
},
})
서버 오류는 클라이언트 검증 오류보다 우선합니다. 사용자가 email 필드를 변경하면, email에 대한 서버 오류가 자동으로 사라집니다—수동으로 정리할 필요가 없습니다.
Per‑Field Async Validation
Some fields need their own async check — e.g., “is this username available?” — independent of the schema:
const form = useForm(schema, {
initialValues: { username: '', email: '' },
fieldValidators: {
username: async (value) => {
const taken = await api.checkUsername(value)
return taken ? 'Username is already taken' : undefined
},
},
})
// Show loading state while checking
{form.validatingFields.username && <span>Checking...</span>}
Field validators only run after schema validation passes for that field. Their errors are stored separately so they aren’t overwritten when the schema revalidates.
이미 Zod 또는 Valibot을 사용하고 있나요?
이 훅은 모든 Standard Schema v1 검증기를 받아들입니다. Zod나 Valibot에 이미 투자했으면 어댑터 없이 바로 사용할 수 있습니다 — 별도의 어댑터가 필요 없습니다:
import { z } from 'zod'
import { useForm } from '@railway-ts/use-form'
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
})
type FormValues = z.infer
// 바로 작동 — resolver도, adapter도 필요 없음
const form = useForm(schema, {
initialValues: { email: '', password: '' },
})
같은 훅, 같은 타입이 지정된 필드 props, 모든 것이 동일합니다. Standard Schema 프로토콜은 훅이 어떤 검증 라이브러리에서 스키마가 생성되었는지 신경 쓰지 않음을 의미합니다.
What It’s Built On
폼 훅은 @railway-ts 라는 작은 생태계의 일부입니다. 검증은 Result 타입을 사용하는 함수형 파이프라인 라이브러리로 구동됩니다 — 값은 Ok(유효) 혹은 Err(오류 목록) 중 하나입니다. 오류는 첫 번째 실패에서 바로 중단되지 않고, 모든 필드를 한 번에 검사하면서 누적됩니다.
폼 훅을 사용하기 위해 위 내용을 알 필요는 없지만, 조합 가능한 검증 파이프라인, 타입이 지정된 오류 처리, 혹은 연산 체이닝을 위한 pipe/flow가 필요하다면 이 구성 요소들을 활용할 수 있습니다.
- 폼 훅 크기: 약 3.6 kB
- 전체 파이프라인 라이브러리 크기: 약 4.2 kB (두 개 모두 트리‑쉐이킹 가능)
시도해 보기
npm add @railway-ts/use-form @railway-ts/pipelines
- GitHub
- Getting Started — 첫 번째 폼부터 배열까지 단계별 가이드
- Live Demo on StackBlitz
- Recipes — Material UI, Chakra UI, 테스트 패턴, 성능 팁
React 18 및 19와 호환됩니다. MIT 라이선스.