State-based React forms
react-state-form-provider
ref 없이, register() 없이, 스키마 라이브러리 결합 없이.No refs, no register(), no schema-library coupling.
§개요Overview
솔직히, 간단한 폼에 폼 라이브러리는 필요 없습니다.Honestly — for a simple form, don't reach for a form library at all.
저도 폼 라이브러리를 만들었지만, 이건 "이걸 쓰세요"가 아니라 "나는 폼을 이렇게 만든다"는 기록에 가깝습니다.It's a strange thing to say while shipping one — but this isn't "use this instead." It's closer to a record of how I build forms.
리액트의 강점은 UI와 데이터의 일치성입니다. ref로 DOM에서 값을 직접 읽는 라이브러리는 그 강점을 버립니다. 그리고 간단한 폼은 굳이 라이브러리를 쓸 필요가 없습니다 — 입력 몇 개는 state로 직접 다루는 편이 오히려 단순합니다. ref 기반 라이브러리(react-hook-form의 기본 방식)라면 더욱 그렇습니다.React's strength is that the UI is a function of your data (state) — screen and value always agree. A library that reads values out of the DOM via refs gives that up. And a simple form doesn't really need a library at all — handling a few inputs as state directly is simpler, all the more so than reaching for a ref-based one (react-hook-form's default mode).
리렌더가 걱정이라면 — 필드 몇 개짜리 폼은 입력할 때마다 다시 그려도 문제없습니다. 터지지 않습니다. 리렌더 최적화가 필요한 것은 필드가 수십·수백 개인 큰 폼이며, 그것은 그때 고민하면 됩니다.Worried about re-renders? A form with a handful of fields can repaint on every keystroke and not care. It won't break. Re-render optimization earns its keep on large forms with dozens or hundreds of fields — weigh your tools then.
그래서 이 라이브러리가 말하려는 것은 "이걸 쓰라"가 아닙니다. 간단한 것은 라이브러리 없이 직접 만들고, ref 대신 state를 쓰라는 것입니다. 이 문서는 그 방법을 코드로 정리한 것입니다.So the real message here isn't "adopt this library" — it's: build simple things yourself, without a library, and use state, not refs. This doc is just that approach, written down as code.
정리하면 세 가지입니다: ① ref 없음 — 값은 전부 state. ② register() 없음 — 입력은 name + value + onChange로 직접 연결. ③ 스키마 결합 없음 — 검증은 그냥 함수이므로, zod를 쓰고 싶으면 검증기 안에서 직접 호출합니다.
The approach, in three: ① No refs — everything is state. ② No register() — you wire name + value + onChange yourself. ③ No schema coupling — validation is just functions; want zod/yup, call it inside a validator.
react·react-dom >=18 이 peer dependency. 현재 버전 0.1.0.react·react-dom >=18 are peer dependencies. Current version 0.1.0.§설치Install
전체 export: Everything exported:
FormProvider, useForm, useFormContext, FormContext, validator, regex, formErrorTempl 와 타입들.plus types.
§멘탈 모델Mental model
name이 배선의 열쇠name is the wiring key
입력의 name 하나가 value.email · validateMap.email · error.email · touched.email을 모두 잇습니다. onChange는 e.target.name을 읽어 어느 필드가 바뀌었는지 압니다. 그래서 name은 반드시 폼 타입 T의 키와 일치해야 합니다.A single name links value.email · validateMap.email · error.email · touched.email. onChange reads e.target.name to know which field changed — so name must match a key of your form type T.
일찍 검증하고, 늦게 불평한다Validate early, complain late
검증은 onChange마다 돕니다(입력 즉시 error 갱신). 하지만 표시는 touched로 게이팅해서, 필드를 벗어나기(onBlur) 전엔 조용합니다. 제출 시 모든 필드가 강제로 touched 처리돼 남은 에러가 한꺼번에 드러납니다.Validation runs on every onChange (so error updates immediately), but display is gated on touched — quiet until the field is left (onBlur). On submit every field is forced touched, surfacing all remaining errors at once.
그런데 왜 그렇게 동작할까요? 이건 라이브러리가 error와 touched를 별개의 상태로 쪼갠 것 하나 때문입니다. 둘은 서로 다른 이벤트에 묶여 있어요:But why does it behave that way? It comes down to one thing: the library splits error and touched into separate states, each driven by a different event:
onChange마다 갱신 — 키 하나 칠 때마다 검증이 돌아 언제나 최신. 일찍 검증이 여기서 나옵니다.Updated on every onChange — validation runs each keystroke, so it's always current. This is the validate early half.
onBlur에서 true, 제출 시 전 필드 true. 표시를 여기에 게이팅해서 아직 안 건드린 칸엔 조용합니다. 늦게 불평이 여기서 나와요.true on onBlur, all-true on submit. Gating the display on it keeps untouched fields quiet. This is the complain late half.
이메일 칸에 타이핑하는 흐름을 그대로 따라가면:Follow the flow of typing into an email field:
| 입력 값value | 이벤트event | error | touched | 화면on screen |
|---|---|---|---|---|
| "a@" | 타이핑typing | ● 틀림invalid | false | — 조용함quiet |
| "a@b" | 타이핑typing | ● 틀림invalid | false | — 조용함quiet |
| (blur) | 칸을 벗어남field loses focus | ● 틀림invalid | true | ✓ 에러 메시지 등장message appears |
| "a@b.com" | 타이핑typing | ○ 통과valid | true | — 메시지 사라짐 (live)message clears (live) |
error는 첫 글자부터 이미 "틀림"으로 계산돼 있지만, touched가 false인 동안 게이트가 닫혀 있어 화면은 조용합니다. blur하는 순간 게이트가 열려 지연 없이 메시지가 뜨고, 고치면 onChange가 계속 검증하므로 실시간으로 사라집니다.error is already "invalid" from the first character, but while touched is false the gate is shut and the screen stays quiet. The moment you blur, the gate opens and the message appears with no lag; fixing it clears the message live, because onChange keeps validating.
이 분리가 없다면 — blur에서만 검증했다면 — 벗어나야 비로소 틀린 줄 알고(반응 느림), 고치는 중에도 실시간 갱신이 안 돼 뻑뻑했을 겁니다. 그래서 "매번 검증하되, 표시는 touched로 미룬다"가 핵심이에요.Without that split — if you validated only on blur — you'd learn of the error only after leaving (laggy), and it wouldn't update live as you fix it. That's why the point is "validate on every change, but defer the display to touched."
value·error·touched는 직접 배선하는 원시값이라, 게이트를 바꾸면 UX가 바뀝니다 — touched를 빼면 즉시 표시, 자체 submitted 플래그로 게이팅하면 제출 후에만 표시.This is a convention, not a lock. value·error·touched are primitives you wire yourself, so changing the gate changes the UX — drop touched for show immediately, or gate on your own submitted flag for show only after submit.isDirty/dirtyFields는 "현재 값 vs baseline", onReset은 baseline으로 복원. 자세히는 baseline 섹션.The hook keeps a baseline internally. isDirty/dirtyFields are "current vs baseline"; onReset restores to it. See the baseline section.▶라이브 데모Live demo
아래는 실제로 동작하는 폼입니다. 서버에 저장된 값을 seed로 둔 "계정 설정" 편집 폼이며, 이 문서에서 설명하는 동작을 그대로 만져볼 수 있습니다.A real, working form below — an account-settings editor seeded with a saved server record. Try everything this doc describes.
taken@example.com입력 → 중복 검사(async) ·server@error.com으로 저장 → 서버 에러 주입taken@example.com→ async duplicate check · saveserver@error.com→ injected server error- 필드를 수정하면
isDirty·dirtyFields가 바뀌고, 저장에 성공하면 baseline이 이동해 다시false가 됩니다.Edit a field andisDirty/dirtyFieldschange; a successful save re-baselines back tofalse.
1FormProvider render-prop
가장 흔한 패턴. 옵션을 넘기면 내부에서 useForm을 부르고 <form onSubmit={handleSubmit} noValidate>로 감싸줍니다.The common pattern. It calls useForm internally and wraps children in <form onSubmit={handleSubmit} noValidate>.
onSubmit prop에 씁니다 — 위 예시가 그대로 정답. handleSubmit은 FormProvider가 내부에서 <form onSubmit>에 걸어 당신의 onSubmit을 대신 호출하는 배관이라 만질 일이 없어요. 그래서 render-prop 인자(FormRenderProps<T>)에도 일부러 안 들어있습니다. 필요한 건 type="submit" 버튼 하나 (자기 <form>을 또 만들면 중첩 form이 됩니다). 프로그램적으로 제출을 직접 호출해야 할 때만 useForm/useFormContext의 handleSubmit을 씁니다.Submit logic goes in the onSubmit prop — the example above is exactly right. handleSubmit is internal plumbing: FormProvider wires it to <form onSubmit> to call your onSubmit for you, so you never touch it — that's why it's intentionally absent from the render-prop arg (FormRenderProps<T>). All you add is a type="submit" button (making your own <form> would nest forms). Reach for handleSubmit on useForm/useFormContext only when you need to trigger submit programmatically.children은 render-prop 함수 또는 평범한 ReactNode 둘 다 됩니다. ReactNode면 내부 자식은 useFormContext로 값을 읽습니다.children may be a render-prop function or a plain ReactNode. If a ReactNode, inner children read values via useFormContext.
2useForm hook
FormProvider는 useForm을 감싼 얇은 래퍼일 뿐입니다. 모든 상태·검증·handleSubmit 로직은 이 훅에 있어요. Context 없이 마크업을 직접 소유하고 싶을 때 씁니다. FormProvider와 취향으로 고르는 것이지 기능 차이가 아닙니다.FormProvider is a thin wrapper over useForm. All state, validation, and handleSubmit live in the hook. Reach for it when you'd rather own every tag without a Context Provider. It's a matter of taste vs FormProvider, not features.
내 <form> 태그로With your own <form>
<form> 없이Without any <form>
handleSubmit의 이벤트는 선택이며 e?.preventDefault()에만 쓰입니다. 버튼 onClick에 직접 걸거나 이벤트 없이 handleSubmit()로 호출해도 됩니다.The event on handleSubmit is optional and only used for e?.preventDefault(). Wire it to a button's onClick, or call handleSubmit() with no event.
3useFormContext not recommended
깊은 자식에서 폼 값을 읽는 용도. 하지만 권장하지 않습니다 — 값은 props로 내려주는 편이 테스트·재사용·타입 추론에 낫습니다.For reading form values in deep children — but not recommended. Passing values down as props is better for testing, reuse, and type inference.
그래도 써야 한다면: FormProvider 밖에서 부르면 throw 합니다. 반환값은 FormApi<T>라 render-prop과 달리 handleSubmit도 포함합니다 (Context에는 전체 api가 실립니다).If you must: it throws when called outside FormProvider. It returns FormApi<T> — so unlike the render-prop it does include handleSubmit (the Context carries the full api).
§validateMap
폼당 하나. 정규식·메시지·검증기를 각각 컬렉션으로 모아두고 name 키로 조립합니다. 한 필드의 검증이 한 줄로 다 보입니다.One per form. Collect patterns, messages, and validators separately, then assemble by name. Every check on a field reads in one line.
TValidatorMap<T>는 { [K in keyof T]?: Validator<T[K], T>[] } — 각 필드에 검증기 배열. 검증기 타입은 (value, allValues) => TValidationResult | null | Promise<…>. 통과는 null 또는 {hasError:false}, 실패는 {hasError:true, message}.TValidatorMap<T> is { [K in keyof T]?: Validator<T[K], T>[] } — an array of validators per field. A validator is (value, allValues) => TValidationResult | null | Promise<…>. Pass = null or {hasError:false}; fail = {hasError:true, message}.
§validator · regex · formErrorTempl
검증기는 결국 그냥 함수입니다 — (value, allValues) => 결과. 라이브러리는 그 함수를 부르기만 하므로 안에서 뭘 하든 자유예요: 정규식 검사, fetch, 여러 검사 순차 체인, 심지어 디바운스까지. 아래 셋은 그 함수를 편하게 쓰는 도구일 뿐, 검증 정책(어떤 정규식을 쓸지 등)은 갖지 않습니다.A validator is ultimately just a function — (value, allValues) => result. The library only calls it, so you can do anything inside: a regex test, a fetch, a sequential chain of checks, even debounce. The three below are just conveniences for writing that function; the library holds no policy (which regex to use, etc.).
true→통과, false→message 에러. 항상 Promise를 반환합니다.Wraps a predicate into a validator (sync or async). true→pass, false→error with message. Always returns a Promise.validator(v => pattern.test(v), message)의 한 줄 단축형. 어떤 패턴을 쓸지는 사용자 몫.One-line shorthand for validator(v => pattern.test(v), message). Which pattern to use is up to you.{hasError, message} 빌더. 메시지가 값에 따라 동적일 때 직접 씁니다.A {hasError, message} builder. Use it directly when the message is dynamic (depends on the value).zod를 원하면? 어댑터는 없지만 검증기 안에서 직접 부르면 됩니다 — safeParse 결과를 formErrorTempl로 변환. 라이브러리는 zod를 모릅니다.Want zod? No adapter, but call it inside a validator — map safeParse to formErrorTempl. The library doesn't know about zod.
§우선순위 & sync/asyncPriority & sync/async
한 필드의 검증기 배열은 순서대로, 순차적으로 실행되고 첫 에러에서 멈춥니다. 그 순서가 곧 우선순위예요 — "필수 먼저, 그다음 길이"는 if를 쌓지 말고 배열에 순서대로 나열합니다. 비싼 async 검증(서버 확인)은 배열 맨 뒤에 두세요. 앞의 싼 sync 검사가 먼저 실패하면 네트워크 호출을 통째로 건너뜁니다.A field's validators run in order, sequentially, and short-circuit on the first error. That order is the priority — for "required first, then length," don't stack ifs; just list them in order. Put expensive async checks (server calls) last: a cheaper sync check failing first skips the network call entirely.
Promise.all)로 검증하고, 한 필드 안에서는 순차로 돕니다. handleSubmit은 모든 async 검증이 끝나길 기다린 뒤 결과를 판정합니다.On submit, fields validate in parallel (Promise.all) while each field's validators run sequentially. handleSubmit awaits all async validators before deciding.§교차 필드 검증Cross-field validation
검증기 두 번째 인자 allValues로 다른 필드를 참조할 수 있습니다(예: 비밀번호 확인). 주의: onChange는 방금 입력한 그 필드만 재검증합니다. 그래서 password를 고쳐도 passwordConfirm은 live로 다시 검사되지 않아요(제출 시엔 전체 검증이라 잡힙니다). live 재검증이 필요하면 의존 필드를 setValue로 다시 밀어 주세요.A validator's second arg allValues lets you reference other fields (e.g. confirm password). Note: onChange only re-validates the field you just edited. So editing password does not re-check passwordConfirm live (submit validates everything, so it's caught there). For live re-validation, push the dependent field again with setValue.
§async 검증 — 최신 요청이 이긴다Async — the latest request wins
한마디로: async 검증기를 넣어도 가장 마지막에 시작된 요청의 응답만 반영됩니다. "늦게 도착한 응답"이 아니라 "최신 요청의 응답"이에요.In one line: with any async validator, only the response of the latest-fired request is applied — not "the last response to arrive," but the "latest request's response."
그래서 느린 옛 요청이 뒤늦게 도착해도 엉뚱한 에러로 덮어쓰지 않아요. 덕분에 AbortController나 순서 보정 같은 걸 직접 짤 필요가 없습니다 — 어떤 async 검사든 그냥 넣으면 순서가 안전. (이 안전은 디바운스와 무관하게 항상 켜져 있어요. 디바운스는 호출 수를 줄이는 별개 얘기.) 내부적으론 필드마다 번호를 매겨, 최신 번호가 아닌 결과를 무시하는 방식입니다. onReset/defaultValues 변경 시 진행 중이던 검증도 전부 무효 처리.So a slow old request landing late won't clobber with a wrong error — meaning you never write AbortController or ordering code yourself. Any async check is safe to just drop in. (This safety is always on, independent of debounce, which is a separate concern about call count.) Internally each field is numbered; results that aren't the latest number are ignored, and onReset/defaultValues changes void in-flight validations too.
isValidating[name]으로 특정 필드가 검증 중인지 알 수 있습니다 — "확인 중…" 스피너용.Use isValidating[name] to know if a field is validating — for a "checking…" spinner.
불필요한 호출 줄이기 — 디바운스 (선택)Trimming wasted calls — debounce (optional)
이건 라이브러리 기능이 아니라, "검증기는 그냥 함수"라서 가능한 한 예시일 뿐이에요 — debounceAsync는 export되지 않는, 당신이 직접 짜는 유틸입니다(훅 아님, 그냥 함수). 결과는 이미 경쟁 보호로 안전하니 디바운스는 순전히 낭비 fetch를 줄이는 선택적 최적화고, 간단한 폼이면 안 써도 됩니다. 값 넣는 경로가 onChange/setValue(둘 다 검증)뿐이라 늦출 지점은 검증기 함수 안뿐이에요. 타이머를 들고 있으니 모듈 스코프(또는 useMemo/useRef)에 한 번만 만드세요 — 렌더마다 새로 만들면 타이머 기억을 잃어 디바운스가 작동하지 않습니다.This is not a library feature — just one example of "a validator is a plain function." debounceAsync is a util you write yourself (not exported, and it's a plain function, not a hook). Results are already safe via race protection, so debounce is purely an optional optimization to cut wasted fetches — skip it for simple forms. Since values only enter via onChange/setValue (both validate), the only place to defer is inside the validator. It holds a timer, so create it once at module scope (or useMemo/useRef) — rebuilt each render, it forgets its timer and never debounces.
§handleSubmit 파이프라인The handleSubmit pipeline
소스 기준, handleSubmit(e?)는 정확히 이 순서로 돕니다:Straight from the source, handleSubmit(e?) runs in exactly this order:
e?.preventDefault()— 이벤트가 있으면.e?.preventDefault()— if an event was passed.submitOnlyWhenDirty가 켜져 있고 dirty가 아니면 여기서 종료(검증도 안 함).IfsubmitOnlyWhenDirtyis on and not dirty, return here (no validation).- 모든 필드 검증(
Promise.all, async 끝까지 대기) →error갱신.Validate all fields (Promise.all, awaiting async) → seterror. - 모든 필드를 touched로 표시 → 남은 에러가 다 보임.Mark every field touched → all remaining errors show.
- 에러가 하나라도 있으면 종료(
onSubmit호출 안 함).If any error, return (onSubmitis not called). - payload 구성:
excludeKey제외 + (onlyDirtyFields면) 바뀐 필드만.Build payload: dropexcludeKey+ (ifonlyDirtyFields) only changed fields. await onSubmit(payload, setErrors, onReset).await onSubmit(payload, setErrors, onReset).- throw 없이 끝나면 현재 값 전체를 새 baseline으로(
isDirty→false).If it resolved without throwing, the full current values become the new baseline (isDirty→false).
onSubmit이 throw하면 handleSubmit이 try/catch로 삼키고 console.error만 합니다 — 절대 reject하지 않고 재-baseline도 안 합니다. 제출 실패 UI가 필요하면 onSubmit 안에서 직접 try/catch 하세요. 그리고 baseline에 담기는 건 필터된 payload가 아니라 제외 키 포함 전체 값입니다.A thrown onSubmit is swallowed (console.error only) — it never rejects and does not re-baseline. For a submit-failure UI, try/catch inside onSubmit. Also, the baseline captures the full values (including excluded keys), not the filtered payload.§서버 에러 — setErrorsServer errors — setErrors
onSubmit은 인자를 셋 받습니다 — (data, setErrors, onReset). 서버 검증 에러를 특정 필드에 꽂거나 성공 후 리셋할 때.onSubmit takes three args — (data, setErrors, onReset). Pin server errors on a field, or reset after success.
setErrors({name, error, message})는 한 필드에 에러를 씁니다. 제출 시 이미 전 필드가 touched라 메시지가 바로 보입니다.setErrors({name, error, message}) writes an error onto one field. Since submit already forced every field touched, it shows immediately.
§payload 옵션Payload options
onSubmit payload에서 제외(검증은 그대로 돎). 타입에서도 Omit<T,K>로 빠져 data.passwordConfirm은 컴파일 에러. 확인 필드에 딱.Drops those keys from the onSubmit payload (still validated). Also Omit<T,K>-ed from the type, so data.passwordConfirm is a compile error. Perfect for confirm fields.data 타입이 Partial<…>로 좁혀지므로 제네릭을 명시하지 말고 추론에 맡기세요.On → only changed fields in the payload (automatic PATCH). data is narrowed to Partial<…>, so let it infer — don't pass explicit generics.handleSubmit이 검증도 없이 즉시 종료 — onSubmit 미호출.On → when nothing changed, handleSubmit returns immediately without validating; onSubmit never runs.§baseline — dirty · resetThe baseline — dirty · reset
훅은 baseline(초기 상태로 취급하는 값)을 내부에 둡니다. baseline이 움직이는 순간은 셋입니다:The hook holds a baseline — the values it treats as "initial." It moves at three moments:
defaultValues의 deep clone.baseline = a deep clone of defaultValues.onSubmit이 throw 없이 끝나면 제출값이 새 baseline → isDirty가 false로.After onSubmit resolves without throwing, the submitted values become the new baseline → isDirty flips false.defaultValues 내용 변경defaultValues content changeisDirty는 저장 버튼 토글에 딱이에요 — 콜백이 아니라 읽는 값. 제출 성공 후 다시 false가 되므로 disabled={!isDirty} Save 버튼은 저장 뒤 자동으로 비활성됩니다.isDirty is ideal for a Save button — a value you read, not a callback. It returns to false after a successful submit, so a disabled={!isDirty} Save button re-disables itself after saving.
onReset()은 현재 baseline으로 복원합니다 — 최초 defaultValues가 아니라, 제출 후라면 제출된 값으로요.onReset() restores to the current baseline — not the original defaultValues, but the submitted values if a submit happened.§비동기 초기값Async initial data
defaultValues 내용이 바뀌면 폼이 재초기화되므로, 데이터 로딩 전에 렌더하고 응답이 오면 폼이 스스로 채워지게 둘 수 있습니다. 데이터 도착 전엔 빈 값, 도착 후 그 값으로 재초기화되고 isDirty는 사용자가 편집하기 전까진 false.Because the form re-initializes when defaultValues changes, you can render before data loads and let the form populate itself when it arrives. Empty first, re-initialized to the data on arrival, and isDirty stays false until the user edits.
setValues로 적용하세요. 또 defaultValues는 structuredClone 되므로 구조화 복제 가능한 값이어야 합니다(함수·클래스 인스턴스 불가).A content change replaces in-progress edits. To keep edits across a refresh, hold incoming data in your own state and apply it with setValues. Also, defaultValues is structuredCloned, so it must be structured-cloneable (no functions or class instances).§입력 타입Input types
폼 레벨 onChange는 type="checkbox"일 때만 e.target.checked를 읽고, 나머지는 전부 e.target.value를 읽습니다.The form-level onChange reads e.target.checked only for type="checkbox"; everything else reads e.target.value.
- checkbox —
value가 아니라checked로 배선.wire withchecked, notvalue. - number —
e.target.value는 항상 문자열이라 폼엔"42"가 저장됩니다. 진짜 숫자가 필요하면setValue로e.target.valueAsNumber를 직접 넣으세요.e.target.valueis always a string, so the form stores"42". For a real number, feede.target.valueAsNumberviasetValue. - radio · select · textarea —
name+value+onChange로 동일하게 배선.wire the same way withname+value+onChange.
§setValue · setValues
DOM 이벤트가 아니라 코드에서 폼에 값을 씁니다. 둘 다 onChange처럼 검증까지 돕니다.Write to the form from code, not a DOM event. Both validate, just like onChange.
setValues는 값이 undefined인 키를 검증하지 않고 건너뜁니다. 필드를 비우려면 ''/false를 넘기세요(undefined 말고).setValues skips keys whose value is undefined (left unvalidated). To clear a field pass ''/false, not undefined.APIuseForm(options)
옵션. FormProvider도 (children 빼고) 같은 옵션을 받습니다.Options. FormProvider takes the same ones (minus children).
structuredClone 됨.Initial values = initial baseline. structuredCloned at mount.data는 Partial).Only changed fields in the payload (data is Partial).APIFormApi<T> — 반환값— returned
error.x?.hasError로 접근.Per-field error. Absent until validated — access as error.x?.hasError.onBlur에서 true, 제출 시 전 필드 true.true on onBlur; all true on submit.true로 존재.Only fields differing from baseline are present (and true).onChange는 name/value(또는 checkbox의 checked)를 읽어 갱신+검증, onBlur는 touched 표시.Wire to inputs. onChange reads name/value (or a checkbox's checked) to update+validate; onBlur marks touched.onSubmit. 이벤트 선택. render-prop엔 없고 useForm/useFormContext에만 있습니다.Validate → onSubmit on pass. Event optional. Not on the render-prop — only useForm/useFormContext.APIFormProvider props
useForm 옵션 전부 + children. children은 (props: FormRenderProps<T>) => JSX 함수 또는 ReactNode. 내부를 <form onSubmit noValidate>로 감싸고 Context Provider도 제공합니다.All useForm options + children. children is a (props: FormRenderProps<T>) => JSX function or a ReactNode. It wraps the inside in <form onSubmit noValidate> and provides the Context.
useFormContext<T>() — FormApi<T> 반환(handleSubmit 포함). Provider 밖이면 throw.returns FormApi<T> (incl. handleSubmit). Throws outside a Provider.
API타입Types
!함정 정리Gotchas at a glance
- FormProvider 제출은
onSubmitprop으로.handleSubmit은 내부 배관이라 render-prop엔 없음(안 써도 됨). 프로그램적 제출이 필요할 때만 useForm/useFormContext.FormProvider submit goes in theonSubmitprop.handleSubmitis internal plumbing, absent from the render-prop (you don't need it). Only for programmatic submit: useForm/useFormContext. error/touched는 검증 전undefined. 항상?.로 접근.error/touchedareundefineduntil validated. Always use?..- 교차 필드는 편집한 필드만 재검증. 의존 필드는
setValue로 유발.Cross-field only re-validates the edited field. Trigger dependents withsetValue. number는 문자열을 준다. 진짜 숫자는valueAsNumber+setValue.numbergives a string. For a real number usevalueAsNumber+setValue.- throw한
onSubmit은 삼켜지고 재-baseline 안 됨. 실패 UI는 onSubmit 안에서 try/catch.A thrownonSubmitis swallowed and doesn't re-baseline. Handle failure inside onSubmit. defaultValues는structuredClone대상. 함수/클래스 인스턴스 불가.defaultValuesisstructuredCloned. No functions or class instances.setValues는undefined키를 건너뛴다. 비우려면''/false.setValuesskipsundefinedkeys. Clear with''/false.