フォームバリデーションライブラリの厚み
フォームバリデーションのライブラリの導入には、なんとなくハードルの高さを感じてしまう。
大抵、導入の際には現場と「ライブラリ固有の世界観」との擦り合わせが必要になって。なんとか収めても改修の折には再び立ちはだかる事もあって。そんな都度、思うことがある。
ブラウザには元からフォームバリデーションの機能を持っている訳で、
-
required/maxlength/type="email"などの制約属性 -
setCustomValidity/validationMessage -
invalidイベント -
checkValidity/reportValidity -
:valid/:invalid擬似クラス
それらを通して繋がり合えたなら、もっと気軽な代物にならないだろうか。
境界としての validationMessage
考えてみたのは、フォームバリデーションをひとつの仕組みとして作るのではなく、validationMessage を境界にして「判定」と「表示」に分割するという形。
判定 -> setCustomValidity / validationMessage -> 表示
間の取り持つのは Web 標準の機能だけ。判定も表示も、好きな技術に自由に差し替えられる。
ということで、この記事はライブラリでもフレームワークでもなく、主に「境界」についての話です。
判定づくり
まずは判定側。今回は例として zod の判定を Web 標準のバリデーションに繋ぐ薄いアダプタを作ってみる。
import * as z from 'zod'
export function bindSchemaToForm(schema, form) {
const handle = () => {
const data = Object.fromEntries(new FormData(form))
const result = schema.safeParse(data)
const errors = result.success ? {} : z.flattenError(result.error).fieldErrors
for (const el of Array.from(form.elements)) {
el.setCustomValidity(errors[el.name]?.[0] ?? '')
}
}
handle()
form.addEventListener('input', handle)
return () => {
form.removeEventListener('input', handle)
}
}
やっていることは単純で、入力値をスキーマで判定し、結果を setCustomValidity に流し込むだけ。zod でなくてもいい。Standard Schema ならより良いかもしれない。ともあれ判定結果は Web 標準のバリデーションに変換される。
これだけで判定は完了。 もし表示をブラウザに任せるなら、他には何もいらない。
仮に Svelte ならこう書ける。
import { bindSchemaToForm } from './bindSchemaToForm'
export function validation(schema) {
return (form) => bindSchemaToForm(schema, form)
}
<script>
import * as z from 'zod'
import { validation } from './validation'
const schema = z.object({
name: z.string().min(3, '名前は3文字以上です').max(20, '名前は20文字以内です'),
email: z.string().min(1, 'メールアドレスは必須です').email('不正なメールアドレスです'),
})
</script>
<form {@attach validation(schema)}>
<label>
名前
<input name="name" />
</label>
<label>
メールアドレス
<input name="email" />
</label>
<button type="submit">送信</button>
</form>
重要なのは、ブラウザから見ればただのフォームであること。あとの表示はブラウザがよしなにやってくれる。
表示づくり
表示側を作るのならこんな感じ。ブラウザの validationMessage を監視して、任意の UI State に流し込むアダプタを作る。
export function observeValidationMessage(scope, adapter) {
const handle = (event) => {
const el = event.target
if (!el.name) return
if (event.type === 'input' && !adapter.has(el.name)) return
if (event.type === 'invalid') event.preventDefault()
adapter.set(el.name, el.validationMessage)
}
scope.addEventListener('input', handle)
scope.addEventListener('focusout', handle)
scope.addEventListener('invalid', handle, { capture: true })
return () => {
scope.removeEventListener('input', handle)
scope.removeEventListener('focusout', handle)
scope.removeEventListener('invalid', handle, { capture: true })
}
}
こちらは判定側を一切気にしない。ただ validationMessage を読み取って、外部の状態に横流しするだけ。フレームワークは問わない。
引き続き Svelte での例。
import { observeValidationMessage } from './observeValidationMessage'
export function validation(errors) {
return (form) => observeValidationMessage(form, {
has: (name) => !!errors[name],
set: (name, value) => (errors[name] = value),
})
}
<script>
import { validation } from './validation'
const errors = $state({})
</script>
<form {@attach validation(errors)}>
<label>
名前
<input required minlength="3" maxlength="20" name="name" />
{#if errors.name}
<span class="error">{errors.name}</span>
{/if}
</label>
<label>
メールアドレス
<input required type="email" name="email" />
{#if errors.email}
<span class="error">{errors.email}</span>
{/if}
</label>
<button type="submit">送信</button>
</form>
これもほんの一例で、CSS の :user-invalid など、もっとブラウザのバリデーション機能を活用してもよさそう。重要なのは、機能そのものはブラウザ任せにして、あくまで見た目の装飾だけを担うということ。
片方だけ使えるということ
この形のいいところは、判定と表示がそれぞれ完全に独立していること。必要なほうだけを使えばいい。
判定だけ導入する場合:
カスタムバリデーションを setCustomValidity に流し込み、表示はブラウザ標準の UI に任せる。reportValidity や validationMessage がそのまま使える。
表示だけ導入する場合:
バリデーション判定は HTML5 組み込み属性などで行い、表示だけカスタマイズする。
HTML5 validation -> validationMessage -> 表示実装 / 任意の UI
もちろん、両方を使うこともできる。
import { bindSchemaToForm } from './bindSchemaToForm'
import { observeValidationMessage } from './observeValidationMessage'
export function validation(schema, errors) {
return (form) => {
const cleanup1 = bindSchemaToForm(schema, form)
const cleanup2 = observeValidationMessage(form, {
has: (name) => name in errors,
set: (name, value) => (errors[name] = value),
})
return () => {
cleanup1()
cleanup2()
}
}
}
<script>
import * as z from 'zod'
import { validation } from './validation'
const schema = z.object({
name: z.string().min(3, '名前は3文字以上です').max(20, '名前は20文字以内です'),
email: z.string().min(1, 'メールアドレスは必須です').email('不正なメールアドレスです'),
})
const errors = $state({})
</script>
<form {@attach validation(schema, errors)}>
<label>
名前
<input name="name" />
{#if errors.name}
<span class="error">{errors.name}</span>
{/if}
</label>
<label>
メールアドレス
<input name="email" />
{#if errors.email}
<span class="error">{errors.email}</span>
{/if}
</label>
<button type="submit">送信</button>
</form>
小さな部品と恩恵
主体はあくまで Web 標準。そして判定と表示、それぞれが独立している。だからこそ、
- 実装が非常に薄くなり、独自の state やルールを強制されない
- 別個のライブラリとして作ることも出来るし、組み合わせることも自由
- ブラウザから見ればあくまで普通のフォームなので、既存のブラウザ機能やユーティリティとごく自然に共存できる
あるいは自分が知らないだけで、既にこういった小さなライブラリは数多く存在しているのかもしれない。
余談:ElementInternals
Custom Element には ElementInternals という API がある。これはブラウザの内部的な仕組みに参加するためのもので、<input> が当たり前に持っている能力、フォームへの値の提供、アクセシビリティ属性の設定、Constraint Validation への参加などを与える事ができる。
つまり普通のフォーム要素の一員になれるということで、今回の仕組みとももちろん合わせることが出来る。
余談:ブラウザネイティブの JSON Schema
Chrome が試作を進めている Prompt API には、JSON Schema を渡して制約を設定することが出来る。これは AI のための仕組みだが、ブラウザが JSON Schema のバリデーションを内部に持つという事実は面白い。もしもこれが育っていつかAPIとして公開されるようなら、スキーマ検証によるフォームバリデーションさえブラウザ標準だけで事足りる、そんな未来もやってくる。
結び
フォームバリデーションについて、ライブラリを丸ごと導入するのではなく、Web 標準の境界に小さな部品を差し込む。
今回は validationMessage を間に挟むことで、判定と表示を驚くほど薄く分離できた。
ブラウザ上で動くものは、結局のところ全て JavaScript や DOM などに収束していく。流行りのフレームワークやメタ言語が変わろうとも、最後に必ず通る標準語は変わらない。
そこを信じて任せてみれば、実装はむしろ小さく、それでいて柔軟になっていく。標準というものの強さがそこにはある。