Value Objectファーストなフォームライブラリを作って、不要だと気づいた話

開発

Value Objectファーストなフォームライブラリ「vorm」を作りました。設計思想は正しかった。でもフォーム全体を再発明する必要はなかった。最終的にRHFのresolverだけ差し込む@vorm/rhfに辿り着いて、ようやく腹落ちしました。

gunubin/vorm TypeScript

動機から失敗まで全部書きます。


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 ObjectEntity
同一性値で決まるemail === emailIDで決まる(同じ名前でも別人)
可変性不変(Immutable)可変(状態が変化する)
ライフサイクルなしあり(作成→更新→削除)
Email, Password, MoneyUser, 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' },
    }),
  },
})

バリデーションルールはEmailPasswordの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にはFormProvideruseFormContextもありません。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文字以上で入力してください',
      },
    }),
  },
})

emailFieldEmailの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のロジックをテストしたい場合は、useFormuseFieldを組み合わせた結合テストで十分です。

// 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 FormTanStack Formvorm
バリデーション定義場所フォーム側(resolver)フォーム側(validators)VO側(ドメイン層)
状態管理uncontrolled(デフォルト)/ controlled(Controller)controlledcontrolled
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のuseFielduseSyncExternalStoreでfield単位のsubscriptionを実現しています。

const email = useField(form, 'email')
const password = useField(form, 'password')

emailの値が変わっても、passworduseFieldは再レンダリングされません。内部の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/zodfromZod()で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サブスクリプション、useFormuseFieldのフルセット。

これ自体の設計は悪くなかったんです。でも実際に使い始めると問題が見えてきました。

  • RHFのパフォーマンス最適化を捨てている。RHFはuncontrolled/refベースで再レンダリングを最小化しています。vormはcontrolledで独自のストアを持つので、RHFが積み上げてきた最適化の恩恵を受けられません。
  • RHFの標準APIから逸脱する。registerの代わりにuseFieldformStateの代わりに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>
  )
}

registerwatchformStateも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 TypehandleSubmitで返る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
Thanks for reading!
ReactフォームTypeScriptValue Objectフロントエンド設計