Formisch

Formisch と Valibot を使った React フォーム構築。

このガイドでは、軽量・スキーマファースト・完全型安全な React 向フォームライブラリ Formisch を使ったフォーム構築を解説します。<Field /> コンポーネントでフォームを作成し、Valibot スキーマで検証し、エラー処理とアクセシビリティを実装する方法を学びます。

デモ

以下のフォームを作成します。シンプルなテキスト入力とテキストエリアを持ちます。送信時にフォームデータを検証し、エラーを表示します。

Component form-formisch-demo not found in registry.

アプローチ

このフォームは、ヘッドレス・スキーマファーストなフォーム管理を実現する Formisch を活用しています。マークアップとスタイリングを完全にコントロールできる <Field /> コンポーネントを使ってフォームを構築します。

  • フォーム状態管理に Formisch の useForm フックを使用します。
  • ネイティブの <form> 要素を送信処理でラップする <Form /> コンポーネントを使用します。
  • 制御された入力のために <Field /> レンダープロップコンポーネントを使用します。
  • Valibot によるスキーマ検証を実装します。
  • スキーマから推準される型安全なフィールドパス。

フォームメソッド

Formisch はフォーム操作をフォームオブジェクトのメソッドではなく、トップレベル関数として公開しています。必要なものだけインポートしてください。

import { getInput, insert, reset, submit } from "@formisch/react"

すべてのメソッドは同じシグネチャを持ちます。第一引数は常にフォームストア第二引数(必要な場合)は常に設定オブジェクトです。

// フィールド値の取得
const email = getInput(form, { path: ["email"] })
 
// フォームを新しい初期値でリセット
reset(form, { initialInput: { email: "", password: "" } })
 
// 配列フィールド内のアイテムを移動
move(form, { path: ["items"], from: 0, to: 3 })

この設計により API は柔軟性と一賫性を保持しています。状態の読み込み(getInputgetErrors)、書き込み(setInputsetErrors)、フォーム制御(submitvalidatefocus)、配列操作(insertremovemoveswapreplace)すべてに同じ (form, config) 形式が用いられます。詳細は full methods reference を参照してください。

アナトミー

Formisch の <Field /> コンポーネントと Ketyap UI の <Field /> コンポーネントを使ったフォームの基本例。

<Form of={form} onSubmit={handleSubmit}>
  <FieldGroup>
    <FormischField of={form} path={["title"]}>
      {(field) => (
        <Field data-invalid={field.errors !== null}>
          <FieldLabel htmlFor="form-title">Bug Title</FieldLabel>
          <Input
            {...field.props}
            id="form-title"
            value={field.input}
            aria-invalid={field.errors !== null}
            placeholder="Login button not working on mobile"
            autoComplete="off"
          />
          <FieldDescription>
            Provide a concise title for your bug report.
          </FieldDescription>
          {field.errors && (
            <FieldError errors={field.errors.map((message) => ({ message }))} />
          )}
        </Field>
      )}
    </FormischField>
  </FieldGroup>
</Form>

フォーム

フォームスキーマの作成

Valibot スキーマでフォームの形式を定義します。Formisch はこのスキーマからインプット・アウトプット型を直接推準します。

form.tsx
import * as v from "valibot"
 
const FormSchema = v.object({
  title: v.pipe(
    v.string(),
    v.minLength(5, "Bug title must be at least 5 characters."),
    v.maxLength(32, "Bug title must be at most 32 characters.")
  ),
  description: v.pipe(
    v.string(),
    v.minLength(20, "Description must be at least 20 characters."),
    v.maxLength(100, "Description must be at most 100 characters.")
  ),
})

フォームのセットアップ

Formisch の useForm フックでフォームインスタンスを作成します。リゾルバーのステップは不要で、スキーマを useForm に直接渡します。

form.tsx
import { Form, Field as FormischField, useForm } from "@formisch/react"
import type { SubmitHandler } from "@formisch/react"
import * as v from "valibot"
 
const FormSchema = v.object({
  title: v.pipe(
    v.string(),
    v.minLength(5, "Bug title must be at least 5 characters."),
    v.maxLength(32, "Bug title must be at most 32 characters.")
  ),
  description: v.pipe(
    v.string(),
    v.minLength(20, "Description must be at least 20 characters."),
    v.maxLength(100, "Description must be at most 100 characters.")
  ),
})
 
export function BugReportForm() {
  const form = useForm({
    schema: FormSchema,
    initialInput: {
      title: "",
      description: "",
    },
  })
 
  const handleSubmit: SubmitHandler<typeof FormSchema> = (output) => {
    // Do something with the validated form values.
    console.log(output)
  }
 
  return (
    <Form of={form} onSubmit={handleSubmit}>
      {/* ... */}
      {/* Build the form here */}
      {/* ... */}
    </Form>
  )
}

<Form /> コンポーネントはネイティブの <form> 要素をラップします。event.preventDefault() を呼び出し、検証を実行し、データが有効なときのみ onSubmit を呼び出します。受け取った output はスキーマから完全に型付けされます。

フォームの構築

Formisch の <Field /> コンポーネントと Ketyap UI の <Field /> コンポーネントでフォームを作成できます。

完成

以上です。クライアントサイド検証付きの完全なアクセシブルなフォームができました。

フォームを送信すると、検証済みデータで handleSubmit が呼び出されます。入力が無効な場合、Formisch が各無効フィールドの field.errors にエラーを入力し、UI に表示されます。

検証

クライアントサイド検証

Formisch は useForm に渡した Valibot スキーマでフォームデータを検証します。リゾルバーは不要——スキーマがランタイム検証と静的型の唯一のソースです。

form.tsx
import { useForm } from "@formisch/react"
 
const FormSchema = v.object({
  title: v.string(),
  description: v.optional(v.string()),
})
 
export function ExampleForm() {
  const form = useForm({
    schema: FormSchema,
    initialInput: {
      title: "",
      description: "",
    },
  })
}

検証モード

Formisch は初回検証と再検証を分離しています。useFormvalidaterevalidate オプションで設定します。

form.tsx
const form = useForm({
  schema: FormSchema,
  validate: "blur",
  revalidate: "input",
})
オプション説明
validate"submit"送信時に検証(デフォルト)。
validate"blur"フィールドがフォーカスを失ったときに検証。
validate"input"入力変更ごとに検証。
validate"initial"フォーム作成時に即座に検証。
revalidate"input"初回実行後、入力変更ごとに再検証(デフォルト)。
revalidate"blur"初回実行後、blur 時に再検証。
revalidate"submit"送信時のみ再検証。

エラーの表示

<FieldError /> でフィールドの横にエラーを表示します。Formisch はエラーを文字列配列で返すので、<FieldError /> が期待する形式にマップしてください。スタイリングとアクセシビリティのため:

  • <Field /> コンポーネントに data-invalid を付けます。
  • <Input /><SelectTrigger /><Checkbox /> などのフォームコントロールに aria-invalid を付けます。
form.tsx
<FormischField of={form} path={["email"]}>
  {(field) => (
    <Field data-invalid={field.errors !== null}>
      <FieldLabel htmlFor="form-email">Email</FieldLabel>
      <Input
        {...field.props}
        id="form-email"
        value={field.input}
        type="email"
        aria-invalid={field.errors !== null}
      />
      {field.errors && (
        <FieldError errors={field.errors.map((message) => ({ message }))} />
      )}
    </Field>
  )}
</FormischField>

フィールドタイプ別の使い方

Formisch はフィールドと要素をバインドする2つの方法を提供します。

  • ネイティブ HTML 要素<Input /><Textarea /> あど)—— field.props をスプレッドし、value={field.input} を提供します。namerefonChangeonBluronFocus は Formisch が自動で設定します。
  • コンポーネントライブラリどの入力(Radix ベースの <Select /><Checkbox /><RadioGroup /><Switch /> など)—— field.input から値を読み取り、field.onChange(value) を呼び出して更新します。

Input

  • 入力フィールドには field.props をスプレッドし、value={field.input} を提供します。
  • エラー表示には <Input />aria-invalid<Field />data-invalid を付けます。

Component form-formisch-input not found in registry.

form.tsx
<FormischField of={form} path={["username"]}>
  {(field) => (
    <Field data-invalid={field.errors !== null}>
      <FieldLabel htmlFor="form-username">Username</FieldLabel>
      <Input
        {...field.props}
        id="form-username"
        value={field.input}
        aria-invalid={field.errors !== null}
      />
      {field.errors && (
        <FieldError errors={field.errors.map((message) => ({ message }))} />
      )}
    </Field>
  )}
</FormischField>

Textarea

  • テキストエリアフィールドには field.props をスプレッドし、value={field.input} を提供します。
  • エラー表示には <Textarea />aria-invalid<Field />data-invalid を付けます。

Component form-formisch-textarea not found in registry.

form.tsx
<FormischField of={form} path={["about"]}>
  {(field) => (
    <Field data-invalid={field.errors !== null}>
      <FieldLabel htmlFor="form-about">More about you</FieldLabel>
      <Textarea
        {...field.props}
        id="form-about"
        value={field.input}
        aria-invalid={field.errors !== null}
        placeholder="I'm a software engineer..."
        className="min-h-[120px]"
      />
      <FieldDescription>
        Tell us more about yourself. This will be used to help us personalize
        your experience.
      </FieldDescription>
      {field.errors && (
        <FieldError errors={field.errors.map((message) => ({ message }))} />
      )}
    </Field>
  )}
</FormischField>

Select

  • Select コンポーネントには field.input を読み取り、<Select />onValueChange から field.onChange を呼び出します。
  • エラー表示には <SelectTrigger />aria-invalid<Field />data-invalid を付けます。

Component form-formisch-select not found in registry.

form.tsx
<FormischField of={form} path={["language"]}>
  {(field) => (
    <Field orientation="responsive" data-invalid={field.errors !== null}>
      <FieldContent>
        <FieldLabel htmlFor="form-language">Spoken Language</FieldLabel>
        <FieldDescription>
          For best results, select the language you speak.
        </FieldDescription>
        {field.errors && (
          <FieldError errors={field.errors.map((message) => ({ message }))} />
        )}
      </FieldContent>
      <Select value={field.input} onValueChange={field.onChange}>
        <SelectTrigger
          id="form-language"
          aria-invalid={field.errors !== null}
          className="min-w-[120px]"
        >
          <SelectValue placeholder="Select" />
        </SelectTrigger>
        <SelectContent position="item-aligned">
          <SelectItem value="auto">Auto</SelectItem>
          <SelectItem value="en">English</SelectItem>
        </SelectContent>
      </Select>
    </Field>
  )}
</FormischField>

Checkbox

  • チェックボックス配列には field.input を読み取り、onCheckedChange から field.onChange を呼び出して更新します。
  • エラー表示には <Checkbox />aria-invalid<Field />data-invalid を付けます。
  • 適切なスタイリングと間距のため、<FieldGroup />data-slot="checkbox-group" を付けてください。

Component form-formisch-checkbox not found in registry.

form.tsx
<FormischField of={form} path={["tasks"]}>
  {(field) => (
    <FieldSet>
      <FieldLegend variant="label">Tasks</FieldLegend>
      <FieldDescription>
        Get notified when tasks you&apos;ve created have updates.
      </FieldDescription>
      <FieldGroup data-slot="checkbox-group">
        {tasks.map((task) => (
          <Field
            key={task.id}
            orientation="horizontal"
            data-invalid={field.errors !== null}
          >
            <Checkbox
              id={`form-checkbox-${task.id}`}
              aria-invalid={field.errors !== null}
              checked={field.input?.includes(task.id) ?? false}
              onCheckedChange={(checked) => {
                const current = field.input ?? []
                field.onChange(
                  checked === true
                    ? [...current, task.id]
                    : current.filter((value) => value !== task.id)
                )
              }}
            />
            <FieldLabel
              htmlFor={`form-checkbox-${task.id}`}
              className="font-normal"
            >
              {task.label}
            </FieldLabel>
          </Field>
        ))}
      </FieldGroup>
      {field.errors && (
        <FieldError errors={field.errors.map((message) => ({ message }))} />
      )}
    </FieldSet>
  )}
</FormischField>

Radio Group

  • ラジオグループには field.input を読み取り、onValueChange から field.onChange を呼び出します。
  • エラー表示には <RadioGroupItem />aria-invalid<Field />data-invalid を付けます。

Component form-formisch-radiogroup not found in registry.

form.tsx
<FormischField of={form} path={["plan"]}>
  {(field) => (
    <FieldSet>
      <FieldLegend>Plan</FieldLegend>
      <FieldDescription>
        You can upgrade or downgrade your plan at any time.
      </FieldDescription>
      <RadioGroup value={field.input} onValueChange={field.onChange}>
        {plans.map((plan) => (
          <FieldLabel key={plan.id} htmlFor={`form-radiogroup-${plan.id}`}>
            <Field
              orientation="horizontal"
              data-invalid={field.errors !== null}
            >
              <FieldContent>
                <FieldTitle>{plan.title}</FieldTitle>
                <FieldDescription>{plan.description}</FieldDescription>
              </FieldContent>
              <RadioGroupItem
                value={plan.id}
                id={`form-radiogroup-${plan.id}`}
                aria-invalid={field.errors !== null}
              />
            </Field>
          </FieldLabel>
        ))}
      </RadioGroup>
      {field.errors && (
        <FieldError errors={field.errors.map((message) => ({ message }))} />
      )}
    </FieldSet>
  )}
</FormischField>

Switch

  • スイッチには field.input を読み取り、onCheckedChange から field.onChange を呼び出します。
  • エラー表示には <Switch />aria-invalid<Field />data-invalid を付けます。

Component form-formisch-switch not found in registry.

form.tsx
<FormischField of={form} path={["twoFactor"]}>
  {(field) => (
    <Field orientation="horizontal" data-invalid={field.errors !== null}>
      <FieldContent>
        <FieldLabel htmlFor="form-twoFactor">
          Multi-factor authentication
        </FieldLabel>
        <FieldDescription>
          Enable multi-factor authentication to secure your account.
        </FieldDescription>
        {field.errors && (
          <FieldError errors={field.errors.map((message) => ({ message }))} />
        )}
      </FieldContent>
      <Switch
        id="form-twoFactor"
        checked={field.input ?? false}
        onCheckedChange={field.onChange}
        aria-invalid={field.errors !== null}
      />
    </Field>
  )}
</FormischField>

Complex Forms

複数のフィールドと検証を持つ、より複雑なフォームの例。

Component form-formisch-complex not found in registry.

フォームのリセット

Formisch はトップレベルの reset 関数を提供します。フォームストアを渡して初期入力にリセットします。

<Button type="button" variant="outline" onClick={() => reset(form)}>
  Reset
</Button>

新しい初期値でリセットしたり、ユーザーの入力内容を保持したままリセットすることもできます。

// 新しい初期値にリセット
reset(form, { initialInput: { title: "", description: "" } })
 
// サーバーデータに基準を同期し、ユーザーの編集内容を保持
reset(form, { initialInput: serverData, keepInput: true })

配列フィールド

Formisch は動的な配列フィールドを管理する <FieldArray /> コンポーネントとヘルパー関数群を提供します。アイテムの追加・削除・並び替えが必要なときに使用してください。

Component form-formisch-array not found in registry.

FieldArray の使い方

<FieldArray /><Field /> と同じレンダープロップパターンを持ちます。items 配列にはアイテムごとに安定したキーが含まれ、React の key に使用するべきです。

form.tsx
import {
  Field as FormischField,
  FieldArray,
  insert,
  remove,
} from "@formisch/react"
 
export function ExampleForm() {
  // ... form config
 
  return (
    <FieldArray of={form} path={["emails"]}>
      {(fieldArray) => (
        <FieldGroup className="gap-4">
          {fieldArray.items.map((item, index) => (
            <FormischField
              key={item}
              of={form}
              path={["emails", index, "address"]}
            >
              {(field) => /* ... */}
            </FormischField>
          ))}
        </FieldGroup>
      )}
    </FieldArray>
  )
}

配列フィールドの構造

<FieldSet /><FieldLegend /><FieldDescription /> を付けて配列フィールドを包みます。

form.tsx
<FieldSet className="gap-4">
  <FieldLegend variant="label">Email Addresses</FieldLegend>
  <FieldDescription>
    Add up to 5 email addresses where we can contact you.
  </FieldDescription>
  <FieldGroup className="gap-4">{/* Array items go here */}</FieldGroup>
</FieldSet>

アイテムの追加

insert 関数で配列に新しいアイテムを追加します。デフォルトでは末尾に追加されます。at インデックスで特定位置に挿入することもできます。

form.tsx
<Button
  type="button"
  variant="outline"
  size="sm"
  onClick={() =>
    insert(form, { path: ["emails"], initialInput: { address: "" } })
  }
  disabled={fieldArray.items.length >= 5}
>
  Add Email Address
</Button>

アイテムの削除

remove 関数に at インデックスを渡して配列からアイテムを削除します。

form.tsx
import { remove } from "@formisch/react"
 
{
  fieldArray.items.length > 1 && (
    <InputGroupAddon align="inline-end">
      <InputGroupButton
        type="button"
        variant="ghost"
        size="icon-xs"
        onClick={() => remove(form, { path: ["emails"], at: index })}
        aria-label={`Remove email ${index + 1}`}
      >
        <XIcon />
      </InputGroupButton>
    </InputGroupAddon>
  )
}

Formisch はアイテムの並び替えや置換のために moveswapreplace も提供しています。いずれも同じ (form, config) シグネチャを持ちます。

配列の検証

Valibot の array とパイプラインバリデータで配列フィールドを検証します。

form.tsx
const FormSchema = v.object({
  emails: v.pipe(
    v.array(
      v.object({
        address: v.pipe(
          v.string(),
          v.nonEmpty("Enter an email address."),
          v.email("Enter a valid email address.")
        ),
      })
    ),
    v.minLength(1, "Add at least one email address."),
    v.maxLength(5, "You can add up to 5 email addresses.")
  ),
})