Value Objectファーストなフォームライブラリ「vorm」を作りました。設計思想は正しかった。でもフォーム全体を再発明する必要はなかった。最終的にRHFのresolverだけ差し込む@vorm/rhfに辿り着いて、ようやく腹落ちしました。
動機から失敗まで全部書きます。
Zodでバリデーションスキーマを書いて、React Hook Formでresolverに渡して、コンポーネント側でもrequiredやminLengthを設定して…
「これ、同じルールを2箇所に書いてない?」
そう思ったのがvormを作ったきっかけです。
既存ライブラリで感じていた3つの課題
1. バリデーションルールの二重定義
よくある構成がこれです。
// ドメイン層: Zodスキーマを定義
const emailSchema = z.string().email('Invalid email')
const passwordSchema = z.string().min(8, 'Too short')
const loginSchema = z.object({
email: emailSchema,
password: passwordSchema,
})
// フォーム層: resolverで渡す...が、UIにもバリデーション情報が必要
const { register, formState: { errors } } = useForm({
resolver: zodResolver(loginSchema),
})
// コンポーネント側でもrequiredを設定
<input {...register('email', { required: true })} />
<input {...register('password', { required: true })} />
Zodのスキーマにはバリデーションルールが書いてある。でもフォーム側でもrequiredを指定している。エラーメッセージはZodのスキーマにも、フォームの設定にも書ける。どっちが正なのか曖昧になるんですよね。
さらに、このZodスキーマをバックエンドのAPIバリデーションにも使いたい場合、「フォーム用に定義したスキーマ」と「ドメイン層のバリデーション」が別物になりがちなんです。
Zodとフォームのバリデーションがズレてバグった経験、何度かあるんですよね。
2. UIコンポーネントがフォームライブラリに依存する
React Hook FormのuseFormContextを使うと、子コンポーネントがRHFに直接依存します。
// ❌ RHFに依存したコンポーネント
function EmailInput() {
const { register, formState: { errors } } = useFormContext()
return (
<div>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
</div>
)
}
これの問題はPresenter分離の記事やhooks注入レベルの記事でも書きましたが、テストとStorybookが面倒になります。
// テスト: FormProviderでラップが必要
render(
<FormProvider {...useForm()}>
<EmailInput />
</FormProvider>
)
// Storybook: デコレーターが必要
export const Default: Story = {
decorators: [(Story) => (
<FormProvider {...useForm()}>
<Story />
</FormProvider>
)],
}
GitHub Discussion #7444でも「Storybookで動かすにはbase componentを別に作って、RHFのwrapperを被せる」という3層構造が提案されています。結局、RHFに依存しないコンポーネントが必要になるわけです。
3. ドメイン層との断絶
ドメイン層の配置の記事で書いたように、ドメインのValue Objectは本来フロントエンドでもバックエンドでも使えるはずです。
// ドメイン層: Emailという概念
type Email = Brand<string, 'Email'>
// バリデーション: @を含む、空でない...
// フォーム層: ???
// ドメインのEmailとフォームのemailフィールドが繋がっていない
React Hook FormもTanStack Formも、フォームの定義がUIの関心事になっています。バリデーションルールは「フォームの設定」として書く。ドメインのValue Objectがバリデーションの情報源であるべきなのに、フォームライブラリがその責務を奪ってしまっています。
自分のプロジェクトで何度もぶつかった
これは机上の話じゃなくて、自分が何度も経験してきたことです。
いくつかの個人プロジェクトで、RHFのラッパーとしてVOファーストなフォームを実装してきました。ドメイン層にZodのブランド型でVOを定義して、フォームスキーマはそのVOを参照するだけ。この構成で個人開発はうまく回っていた。
でも実務のチーム開発だと話が変わります。useFormContextを使って末端のUIコンポーネントがRHFに直接依存していたり、サーバーサイドとフロントエンドでほぼ同じバリデーションロジックが別々に管理されていたり。「フロントエンドはJSONの高級版でしょ」「ブランド型はtoo muchでしょ」という声も実際にあります。
でもフロントエンドにもプレゼンテーショナルロジックは確実に存在する。金額のフォーマット、日付の表示形式、入力値の正規化。これらを純粋関数としてテストしないのは不具合のもとです。フロントエンドにも責務の分割は必要だと思っています。
そう考えると、入力値をブランド型で表現し、値のルールを持つVOとして定義し、それをフォームスキーマとして宣言しておくことは、大げさでもなんでもない。だから大規模プロジェクトに限らず、個人開発でもこの考え方でやっています。
あるとき、このRHFラッパーの構成を人に説明していたら「それもうライブラリだね」と言われた。確かに、RHFのhandleSubmitでVOに変換するだけじゃんとか、RHFで簡単に使いたいだけなのに、という気持ちはわかる。vormはフォームを使うだけなのにステップ数が多い。でも型安全にやりたいんです。だったらライブラリにしてしまおうと。それがvormの始まりでした。
なぜVOがフロントエンドで自然に使えるのか
「ドメイン層をフロントエンドで使う」と聞くと、過剰設計に聞こえるかもしれません。でもValue Objectに限って言えば、フロントエンドとの相性がとても良いです。
DDDにおけるValue Object(VO)とEntity(エンティティ)の違いを整理しておきます。
| 特性 | Value Object | Entity |
|---|---|---|
| 同一性 | 値で決まる(email === email) | IDで決まる(同じ名前でも別人) |
| 可変性 | 不変(Immutable) | 可変(状態が変化する) |
| ライフサイクル | なし | あり(作成→更新→削除) |
| 例 | Email, Password, Money | User, Order, Product |
Entityはライフサイクルを持ちます。Userは作成され、プロフィールが更新され、退会で削除される。この状態管理をフロントエンドで正しく扱うのは大変です。サーバーとの同期、楽観的更新、キャッシュの整合性…考えるだけでしんどい。
一方、VOは不変です。"user@example.com"という文字列がEmailのバリデーションを通過したら、それはEmail型として確定します。状態遷移も、サーバーとの同期も必要ありません。ユーザーが入力した値がバリデーションを通過すれば、それがVOです。
// フォームの入力値: ただのstring
const rawEmail = 'user@example.com'
// バリデーション通過後: Email型(VO)に昇格
const email: Email = Email.create(rawEmail) // Brand<string, 'Email'>
// この瞬間から型システムがEmailを追跡する
// Passwordと取り違えることはできない
これがフォームライブラリとVOの相性が良い理由です。フォームの入力 → バリデーション → VO生成という流れは、ユーザー入力を受け取るフロントエンドの自然な処理フローそのものです。
入力中はVO型にしない
ただし、入力中の値をVO型で扱ってはいけません。ユーザーがフォームに"user@"と途中まで打った状態はEmailとして不正です。不正な値をEmail型にしてしまったら、ブランド型の意味がなくなります。
なのでvormでは、入力中の値とsubmit後の値を型レベルで分離しています。
// フォームスキーマから2つの型が自動導出される
// 入力型: フォーム入力中の値(ただのstring)
type LoginFormInput = { email: string; password: string }
// 出力型: バリデーション通過後の値(VO型)
type LoginFormOutput = { email: Email; password: Password }
// 入力中: string型のまま扱う
const email = useField(form, 'email')
email.value // string(まだバリデーション通過していない)
// submit時: バリデーション通過後にVO型へ昇格
form.handleSubmit((values) => {
values.email // Brand<string, 'Email'>(VO型が保証される)
values.password // Brand<string, 'Password'>
api.login(values) // 型安全にAPIへ渡せる
})
この「入力中は素のstring、バリデーション通過後にVO型」という切り替えが重要です。振る舞いに合った型で扱うことで、「まだ検証されていない値がVO型として流通する」という事故を型システムが防いでくれます。
VOは何にも依存しないので、フロントでもバックエンドでもそのまま使い回せます。
バックエンドのAPIバリデーションでも同じVOを参照すれば、フロントとバックで「Emailとは何か」の定義がズレることはありません。
vormの設計思想
vormは3つの原則で設計しました。
1. VOがバリデーションのSingle Source of Truth
バリデーションルールはValue Objectに1回だけ書きます。フォームはそのルールを参照するだけです。
import { vo } from '@vorm/core'
const Email = vo('Email', [
{ code: 'INVALID_FORMAT', validate: (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) },
])
const Password = vo('Password', [
{ code: 'TOO_SHORT', validate: (v: string) => v.length >= 8 },
{ code: 'NO_UPPERCASE', validate: (v: string) => /[A-Z]/.test(v) },
])
vo()が返すのはVODefinitionで、バリデーションルールとブランド型を持つ。Email.create('user@example.com')はBrand<string, 'Email'>を返す。コンパイル時にEmailとPasswordを取り違えることはできません。
このVOはフォームとは無関係に動きます。APIのバリデーション、バックエンドのドメインロジック、どこでも同じ定義を使えます。
2. 宣言的なスキーマ定義
フォームスキーマは「このフィールドはこのVOです」と宣言するだけです。
import { createField, createFormSchema } from '@vorm/core'
const emailField = createField(Email)
const passwordField = createField(Password)
const loginSchema = createFormSchema({
fields: {
email: emailField({
required: true,
messages: { REQUIRED: 'Email is required', INVALID_FORMAT: 'Invalid email' },
}),
password: passwordField({
required: true,
messages: { REQUIRED: 'Password is required', TOO_SHORT: 'Min 8 characters' },
}),
},
})
バリデーションルールはEmailとPasswordのVOから引き継がれます。フォーム側で追加するのはUIの関心事だけです。必須かどうか、エラーメッセージの日本語化。ルール自体は書きません。
ここで重要なのは、エラーメッセージはVOの責務ではないということです。Zodでスキーマを作るとき、.email('メールアドレスが不正です')のようにメッセージをバリデーションルールにセットで書きがちです。でもそれは責務としておかしいんです。
「メールアドレスが不正です」と表示するか「Invalid email」と表示するかは、画面やロケールによって変わります。登録画面とログイン画面で同じVOを使っていても、エラーメッセージは違うかもしれない。だからVOはエラーコード(INVALID_FORMAT)だけを返して、メッセージはフォームスキーマ側で上書きできるようにしています。
// VOはエラーコードだけ持つ
const Email = vo('Email', [
{ code: 'INVALID_FORMAT', validate: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) },
])
// 登録画面: 日本語メッセージ
emailField({ messages: { INVALID_FORMAT: 'メールアドレスの形式が正しくありません' } })
// 管理画面: 英語メッセージ
emailField({ messages: { INVALID_FORMAT: 'Invalid email format' } })
VOにメッセージを持たせると、i18nのたびにドメイン層を触ることになります。
3. UIコンポーネントはライブラリに依存しない
vormにはFormProviderもuseFormContextもありません。useFieldはフォームオブジェクトをpropsで受け取ります。
import { useForm, useField } from '@vorm/react'
function LoginForm() {
const form = useForm(loginSchema, {
defaultValues: { email: '', password: '' },
mode: 'onBlur',
})
const email = useField(form, 'email')
const password = useField(form, 'password')
return (
<form onSubmit={form.handleSubmit((values) => {
// values.email は Brand<string, 'Email'>
// values.password は Brand<string, 'Password'>
login(values.email, values.password)
})}>
<TextInput
value={email.value}
onChange={email.onChange}
onBlur={email.onBlur}
error={email.error?.message}
/>
<TextInput
value={password.value}
onChange={password.onChange}
onBlur={password.onBlur}
error={password.error?.message}
/>
<button type="submit" disabled={form.isSubmitting}>Login</button>
</form>
)
}
TextInputコンポーネントはvalue, onChange, errorをpropsで受け取るだけです。vormにもRHFにも依存していません。
ドメイン層からフォームまでの流れ
vormの3層構造はこんな感じです。
Layer 1: VO定義(ドメイン層)
// domain/email.ts
import { vo, createRule } from '@vorm/core'
import type { Infer } from '@vorm/core'
const emailPattern = createRule(
'INVALID_FORMAT',
(value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
)
export const Email = vo('Email', [emailPattern()])
export type Email = Infer<typeof Email>
// フォームと無関係に使える
Email.create('user@example.com') // Brand<string, 'Email'>
Email.create('invalid') // throws VOValidationError
Email.safeCreate('invalid') // { success: false, error: { code: 'INVALID_FORMAT' } }
createRuleでルールをパラメータ化できます。minLength(8)のように再利用可能な共通ルールを作れます。
// domain/rules.ts
import { createRule } from '@vorm/core'
export const minLength = createRule(
'TOO_SHORT',
(value: string, min: number) => value.length >= min,
)
export const maxLength = createRule(
'TOO_LONG',
(value: string, max: number) => value.length <= max,
)
// domain/password.ts
import { vo } from '@vorm/core'
import { minLength } from './rules'
export const Password = vo('Password', [minLength(8)])
export type Password = Infer<typeof Password>
Layer 2: フォームスキーマ(フォーム層)
// forms/login-form.ts
import { createField, createFormSchema } from '@vorm/core'
import { Email } from '@/domain/email'
import { Password } from '@/domain/password'
const emailField = createField(Email)
const passwordField = createField(Password)
export const loginSchema = createFormSchema({
fields: {
email: emailField({
required: true,
messages: {
REQUIRED: 'メールアドレスを入力してください',
INVALID_FORMAT: 'メールアドレスの形式が正しくありません',
},
}),
password: passwordField({
required: true,
messages: {
REQUIRED: 'パスワードを入力してください',
TOO_SHORT: '8文字以上で入力してください',
},
}),
},
})
emailFieldはEmailのVOからバリデーションルールを引き継ぎます。フォーム側で指定するのは、必須かどうかとエラーメッセージだけです。
Layer 3: Reactコンポーネント(UI層)
// components/LoginForm.tsx
import { useForm, useField } from '@vorm/react'
import { loginSchema } from '@/forms/login-form'
export function LoginForm() {
const form = useForm(loginSchema, {
defaultValues: { email: '', password: '' },
mode: 'onBlur',
})
return (
<LoginFormView
form={form}
onSubmit={form.handleSubmit(async (values) => {
await api.login(values)
})}
/>
)
}
// View: vormにもRHFにも依存しない
function LoginFormView({ form, onSubmit }) {
const email = useField(form, 'email')
const password = useField(form, 'password')
return (
<form onSubmit={onSubmit}>
<TextInput
value={email.value}
onChange={email.onChange}
onBlur={email.onBlur}
error={email.error?.message}
/>
<TextInput
type="password"
value={password.value}
onChange={password.onChange}
onBlur={password.onBlur}
error={password.error?.message}
/>
<button type="submit" disabled={form.isSubmitting}>
ログイン
</button>
</form>
)
}
テストとStorybookが自然に簡単になる
vormにはFormProviderがないので、テストやStorybookで特別なラッパーがいりません。
RHFの場合
// テスト: FormProviderでラップが必要
test('メールアドレスを表示する', () => {
const methods = useForm({ defaultValues: { email: 'test@example.com' } })
render(
<FormProvider {...methods}>
<EmailInput />
</FormProvider>
)
expect(screen.getByDisplayValue('test@example.com')).toBeInTheDocument()
})
// Storybook: デコレーターが必要
export default {
decorators: [(Story) => (
<FormProvider {...useForm({ defaultValues: { email: '' } })}>
<Story />
</FormProvider>
)],
}
vormの場合
// テスト: そのまま書ける
test('メールアドレスを表示する', () => {
render(
<TextInput
value="test@example.com"
onChange={() => {}}
onBlur={() => {}}
/>
)
expect(screen.getByDisplayValue('test@example.com')).toBeInTheDocument()
})
// Storybook: propsを渡すだけ
export const Default: Story = {
args: {
value: 'test@example.com',
onChange: fn(),
onBlur: fn(),
},
}
FormProviderのラップ、20個のコンポーネントで毎回やるとテスト書く気が失せます。
hooksのロジックをテストしたい場合は、useFormとuseFieldを組み合わせた結合テストで十分です。
// hooks結合テスト
test('バリデーション → 修正 → submit', async () => {
const onSubmit = vi.fn()
const { result } = renderHook(() => {
const form = useForm(loginSchema, {
defaultValues: { email: '', password: '' },
mode: 'onBlur',
})
const email = useField(form, 'email')
const password = useField(form, 'password')
return { form, email, password }
})
// 不正な値を入力 → blur → エラーが出る
act(() => result.current.email.onChange('invalid'))
act(() => result.current.email.onBlur())
expect(result.current.email.error?.code).toBe('INVALID_FORMAT')
// 正しい値に修正 → blur → エラーが消える
act(() => result.current.email.onChange('test@example.com'))
act(() => result.current.email.onBlur())
expect(result.current.email.error).toBeNull()
})
UIコンポーネントのテストとhooksのテストがきれいに分離できます。
既存ライブラリとの比較
| 観点 | React Hook Form | TanStack Form | vorm |
|---|---|---|---|
| バリデーション定義場所 | フォーム側(resolver) | フォーム側(validators) | VO側(ドメイン層) |
| 状態管理 | uncontrolled(デフォルト)/ controlled(Controller) | controlled | controlled |
| UIの依存 | useFormContextでRHFに依存 | useFieldでTanStack Formに依存 | propsのみ、ライブラリ非依存 |
| 型安全性 | 中(resolver依存) | 高 | 高(VO→フォーム型自動導出) |
| ブランド型 | なし | なし | submit後にBrand型を返す |
| ドメインロジック再利用 | 困難 | 困難 | VO経由で自然に再利用 |
| React Native対応 | ref依存で制限あり | 対応可能 | controlled前提で完全対応 |
| 再レンダリング | field単位(ref) | field単位 | field単位(useSyncExternalStore) |
| エコシステム | 巨大 | 成長中 | 新規 |
vormが勝っているのは設計思想のレイヤーです。パフォーマンスやエコシステムではRHFに及びません。ただ、「バリデーションルールはどこに書くべきか?」という問いに対して、vormは明確な答えを持っています。
「RHF + Zodで同じことできない?」
正直なところ、RHFも同じ方向に動き始めています。Zodの.brand()でブランド型を作り、useFormの3番目のジェネリクスで出力型を指定する方法が既にあります。
const EmailSchema = z.string().email().brand<'Email'>()
useForm<z.input<typeof schema>, any, z.output<typeof schema>>({
resolver: zodResolver(schema),
})
理論上はこれでsubmit時にブランド型が返ってきます。でも実際にはhandleSubmitの型推論がz.inputベースのままでうまく動かないissueが複数上がっていて、まだスムーズとは言えません。
もうひとつの問題は、この方法だとバリデーションの定義場所は依然として「Zodスキーマ」であって「VO」ではないことです。VOが持つべきcreate()やsafeCreate()のようなファクトリメソッド、エラーコードの体系化、フォームとは無関係に使えるバリデーション関数。これらはZodスキーマだけでは表現しきれません。
vormが独立ライブラリとして存在する理由はここにあります。RHFに足りない型の隙間を埋めるだけじゃなくて、「バリデーションルールの居場所」をフォーム層からドメイン層に引き上げること。RHFの型推論が改善されたとしても、この設計思想の部分は変わらないと思っています。
その他の機能
per-field再レンダリング
vormのuseFieldはuseSyncExternalStoreでfield単位のsubscriptionを実現しています。
const email = useField(form, 'email')
const password = useField(form, 'password')
emailの値が変わっても、passwordのuseFieldは再レンダリングされません。内部のFormStoreがfield単位のリスナーを管理しています。
一方、form.field('email')というAPIもあります。これはフォームレベルのsnapshotから読むので、どのフィールドが変わっても再レンダリングされます。パフォーマンスを気にする場面ではuseFieldを使うのがおすすめです。
非同期バリデーション
「メールアドレスが既に登録されているか」のような非同期バリデーションにも対応しています。
const form = useForm(loginSchema, {
defaultValues: { email: '', password: '' },
mode: 'onBlur',
asyncValidators: {
email: {
validate: async (value) => {
const taken = await checkEmailExists(value)
if (taken) return { code: 'TAKEN', message: 'Already registered' }
return null
},
on: 'blur',
debounceMs: 300,
},
},
})
同期バリデーション(VOのルール)が先に実行されて、通過した場合のみ非同期バリデーションが走ります。前回のリクエストはAbortControllerで自動キャンセルされるので、レースコンディションの心配もないです。
Zodアダプター
既にZodスキーマを持っている場合、@vorm/zodのfromZod()でvormのバリデーションルールに変換できます。
import { z } from 'zod'
import { fromZod } from '@vorm/zod'
import { vo } from '@vorm/core'
const emailZodSchema = z.string().email('INVALID_EMAIL').min(1, 'REQUIRED')
const Email = vo('Email', fromZod(emailZodSchema))
fromZod()はZodのチェック(min, max, email, regex)を抽出してValidationRule[]に変換します。Zodのエラーメッセージはエラーコードとして扱われます。
既存のZodスキーマを活かしつつ、vormのVO体系に移行できます。全部書き直す必要はありません。
クロスフィールドバリデーション
パスワード確認のような、複数フィールドにまたがるバリデーションはresolverで定義します。
const signupSchema = createFormSchema({
fields: {
password: passwordField({ required: true }),
confirmPassword: createField<string>()({ required: true }),
},
resolver: (values) => {
if (values.password !== values.confirmPassword) {
return {
confirmPassword: { code: 'MISMATCH', message: 'パスワードが一致しません' },
}
}
return null
},
})
resolverはsubmit時に全フィールドのルールが通過した後に実行されます。valuesの型はFormInputValues<TFields>として推論されるので、定義済みフィールドのみ型安全にアクセスできるようになっています。
「VO-first」は正しい表現なのか
ここまで書いておいてなんですが、vormの本質は「VO-first」ではなく「Branded Type-first」かもしれません。
vormのvo()が提供するのはバリデーションルールとブランド型であって、DDDが定義するValue Objectの全てではありません。たとえば住所。都道府県・市区町村・番地の3つのフィールドで1つのVOを構成するケースでは、フォームのフィールドとVOが1:1になりません。vormのcreateFieldは1フィールド1VOを前提にしているので、こういう複合VOには対応しきれていないです。
正確に言えば、vormがやっていることは「フロントエンド用のBranded Typeモデルを定義して、そこにバリデーションルールを集約する」ことです。VOという概念を借りることで「バリデーションの居場所はドメイン層」という方向性が明確になるけど、DDDのVOと完全に一致するわけじゃない。
1フィールド=1VOは例外ではなく多数派
ただし、「VO=フォームの項目という等式が成り立たない」からといって、この設計が破綻しているわけではありません。現実のフォームで多いフィールドを考えてみてください。
- Email, Password, PhoneNumber, PostalCode, Username, DisplayName…
これらは1フィールド=1VOがそのまま成立します。崩れるのは「住所のような複合VO」と「確認用パスワードのようなVOに属さない一時フィールド」くらいで、例外の方が少ないです。vormのcreateField<string>()がescape hatchとしてVO無しのフィールドを作れるので、例外にも対応できます。
ただ、問題は別のところにありました。
ライブラリの境界線を間違えた
vormの@vorm/reactはフォーム全体を再発明していました。独自のFormStore、useSyncExternalStoreによるper-fieldサブスクリプション、useFormとuseFieldのフルセット。
これ自体の設計は悪くなかったんです。でも実際に使い始めると問題が見えてきました。
- RHFのパフォーマンス最適化を捨てている。RHFはuncontrolled/refベースで再レンダリングを最小化しています。vormはcontrolledで独自のストアを持つので、RHFが積み上げてきた最適化の恩恵を受けられません。
- RHFの標準APIから逸脱する。
registerの代わりにuseField、formStateの代わりにform.errors。チームメンバーがRHFを知っていても、vormの独自APIを覚え直す必要があります。 - エコシステムとの断絶。RHFのDevToolsやUI連携ライブラリが使えません。
vormが本当に解決すべきだったのは「バリデーションルールをVOに集約して、submit時にBranded Typeを返す」ことだけでした。フォームの状態管理、再レンダリング最適化、register APIまで自前で作る必要はなかったんです。
@vorm/rhf——resolverだけの薄い統合
そこで作ったのが@vorm/rhfです。RHFのResolverインターフェースに@vorm/coreのバリデーションを差し込むだけの薄いアダプターです。
@vorm/core ← VO定義、ルール、バリデーション(既存)
@vorm/react ← 独自フォーム管理(既存・そのまま残す)
@vorm/rhf ← RHF resolver アダプター(NEW)
@vorm/zod ← Zodアダプター(既存)
使い方
import { useVorm } from '@vorm/rhf'
export function LoginForm() {
// useVorm = RHFのuseForm + vormのresolverを設定するだけ
// 返り値はRHFのUseFormReturnそのもの
const { register, handleSubmit, formState: { errors } } = useVorm(loginSchema, {
defaultValues: { email: '', password: '' },
mode: 'onBlur',
})
return (
<form onSubmit={handleSubmit((values) => {
// values.email → Brand<string, 'Email'>
// values.password → Brand<string, 'Password'>
api.login(values.email, values.password)
})}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input {...register('password')} type="password" />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Login</button>
</form>
)
}
registerもwatchもformStateもRHFそのままです。vormが担当するのは「バリデーション + VO変換」だけです。
resolverの中身
createVormResolverの実装は60行くらいです。
import { validateForm, buildOutputValues } from '@vorm/core'
import type { Resolver } from 'react-hook-form'
export function createVormResolver(schema) {
return (values) => {
// 1. RHFはstringを保持するため、parseを適用してTに変換
const parsed = applyParse(values, schema.fields)
// 2. @vorm/core の validateForm にバリデーションを委譲
const errors = validateForm(parsed, schema)
if (Object.keys(errors).length > 0) {
return { values: {}, errors: toRHFErrors(errors) }
}
// 3. buildOutputValuesでVO.create()を通してBranded Typeに変換
const output = buildOutputValues(parsed, schema.fields)
return { values: output, errors: {} }
}
}
RHFはフォームの値をstringで保持するため、resolver側でapplyParseを使ってドメイン型に変換してからバリデーションを実行します。バリデーションロジック自体は@vorm/coreに既にあり、resolverはそれをRHFの世界に橋渡しするだけです。
@vorm/react との違い
| 観点 | @vorm/react | @vorm/rhf |
|---|---|---|
| 状態管理 | 独自(FormStore + useSyncExternalStore) | RHFに委譲 |
| API | 独自(useField, form.field) | RHF標準(register, watch, formState) |
| 再レンダリング | controlledのみ | uncontrolled(デフォルト)/ controlled(Controller)選択可 |
| 学習コスト | vorm独自APIを覚える | RHFを知っていればOK |
| エコシステム | 独自 | RHFのDevTools等が使える |
| Branded Type | handleSubmitで返る | handleSubmitで返る(同じ) |
どちらもsubmit時にBranded Typeを返す点は同じです。違うのは「フォーム管理を誰がやるか」だけ。RHFを既に使っているなら@vorm/rhf、vormだけで完結させたいなら@vorm/reactを選んでください。
resolverだけ差し込む薄い統合。最初からこれを作るべきでした。
まとめ
設計思想は正しかった。 バリデーションルールをVOに集約する、submit時にBranded Typeを返す、エラーコードとメッセージを分離する。これらの判断は今でも正しいと思っています。
間違えたのはライブラリの境界線でした。フォーム全体を再発明する必要はなかったんです。VOの定義とバリデーション(@vorm/core)だけが本当に必要なコアで、フォームの状態管理はRHFに任せればよかった。resolverを1つ書くだけで、RHFのパフォーマンスもエコシステムもAPIもそのまま使えます。
作ってみないと分からないことってあります。vormは「フォームライブラリ」を作ることで「resolverだけで十分だった」と教えてくれたプロジェクトでした。
gunubin/vorm TypeScript