125k

Formisch

Formisch と Valibot を使用して React でフォームを構築します。

このガイドでは、軽量でスキーマファースト、完全に型安全な React 用フォーム ライブラリ Formisch を使ってフォームを構築します。<Field /> コンポーネントでフォームを作成し、Valibot スキーマで検証し、エラーを処理し、アクセシビリティを確保します。

デモ

次のフォームを構築します。単純なテキスト入力とテキストエリアを備えています。送信時にフォーム データを検証し、エラーを表示します。

コンポーネント form-formisch-demo が見つかりません。

アプローチ

このフォームでは、スタイルを持たないスキーマファーストのフォーム処理に Formisch を利用します。マークアップとスタイルを完全に柔軟に制御できる <Field /> コンポーネントでフォームを構築します。

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

フォーム メソッド

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

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

すべてのメソッドは同じシグネチャに従います。最初のパラメーターは常にフォーム ストアで、2 番目のパラメーター(必要な場合)は常に設定オブジェクトです。

// Read a field value
const email = getInput(form, { path: ["email"] })
 
// Reset the form with new initial values
reset(form, { initialInput: { email: "", password: "" } })
 
// Move an item in a field array
move(form, { path: ["items"], from: 0, to: 3 })

この設計により、すべてのメソッドで API の柔軟性と一貫性が保たれます。このガイド全体で、状態の読み取り(getInput、getErrors)、状態の書き込み(setInput、setErrors)、フォーム制御(submit、validate、focus)、配列操作(insert、remove、move、swap、replace)に同じ (form, config) 形式を使います。詳しくはメソッドの完全なリファレンスを参照してください。

構成

Formisch の <Field /> コンポーネントと shadcn の <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 に直接渡すため、resolver の手順はありません。

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 /> コンポーネントと shadcn の <Field /> コンポーネントを使ってフォームを構築できます。

完了

以上で、クライアント側検証を備えた完全にアクセシブルなフォームが完成しました。

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

バリデーション

クライアント側検証

Formisch は useForm に渡した Valibot スキーマを使ってフォーム データを検証します。resolver はありません。実行時検証と静的型の両方で、スキーマが唯一の信頼できる情報源になります。

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 は最初の検証とその後の検証を分けます。useForm の validate と revalidate オプションで設定します。

form.tsx
const form = useForm({
  schema: FormSchema,
  validate: "blur",
  revalidate: "input",
})
オプション値説明
validate"submit"フォーム送信時に検証します(既定)。
validate"blur"フィールドがフォーカスを失ったときに検証します。
validate"input"入力が変更されるたびに検証します。
validate"initial"フォーム作成時にすぐ検証します。
revalidate"input"初回検証後、入力が変更されるたびに再検証します(既定)。
revalidate"blur"初回検証後、フォーカスを失ったときに再検証します。
revalidate"submit"フォーム送信時だけ再検証します。

エラーの表示

<FieldError /> を使ってフィールドの隣にエラーを表示します。Formisch はエラーを文字列配列として返すため、<FieldError /> が受け取る形にマッピングします。スタイルとアクセシビリティのために次を行います。

  • <Field /> コンポーネントに data-invalid prop を追加します。
  • <Input />、<SelectTrigger />、<Checkbox /> などのフォーム コントロールに aria-invalid prop を追加します。
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} を渡します。Formisch が name、ref、onChange、onBlur、onFocus を設定します。
  • コンポーネント ライブラリの入力(Radix ベースの <Select />、<Checkbox />、<RadioGroup />、<Switch /> など)— field.input から値を読み取り、field.onChange(value) を呼び出して更新します。

入力

  • 入力フィールドでは field.props を展開し、value={field.input} を渡します。
  • エラーを表示するには、<Input /> コンポーネントに aria-invalid prop、<Field /> コンポーネントに data-invalid prop を追加します。

コンポーネント form-formisch-input が見つかりません。

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>

テキストエリア

  • テキストエリア フィールドでは field.props を展開し、value={field.input} を渡します。
  • エラーを表示するには、<Textarea /> コンポーネントに aria-invalid prop、<Field /> コンポーネントに data-invalid prop を追加します。

コンポーネント form-formisch-textarea が見つかりません。

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 コンポーネントでは field.input を読み取り、<Select /> の onValueChange から field.onChange を呼び出します。
  • エラーを表示するには、<SelectTrigger /> コンポーネントに aria-invalid prop、<Field /> コンポーネントに data-invalid prop を追加します。

コンポーネント form-formisch-select が見つかりません。

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>

チェックボックス

  • チェックボックス配列では field.input を読み取り、field.onChange を使って onCheckedChange から更新します。
  • エラーを表示するには、<Checkbox /> コンポーネントに aria-invalid prop、<Field /> コンポーネントに data-invalid prop を追加します。
  • 適切なスタイルと間隔のため、<FieldGroup /> コンポーネントに data-slot="checkbox-group" を追加してください。

コンポーネント form-formisch-checkbox が見つかりません。

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>

ラジオ グループ

  • ラジオ グループでは field.input を読み取り、onValueChange から field.onChange を呼び出します。
  • エラーを表示するには、<RadioGroupItem /> コンポーネントに aria-invalid prop、<Field /> コンポーネントに data-invalid prop を追加します。

コンポーネント form-formisch-radiogroup が見つかりません。

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>

スイッチ

  • スイッチでは field.input を読み取り、onCheckedChange から field.onChange を呼び出します。
  • エラーを表示するには、<Switch /> コンポーネントに aria-invalid prop、<Field /> コンポーネントに data-invalid prop を追加します。

コンポーネント form-formisch-switch が見つかりません。

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>

複雑なフォーム

複数のフィールドと検証を含む、より複雑なフォームの例です。

コンポーネント form-formisch-complex が見つかりません。

フォームをリセットする

Formisch はトップレベルの reset 関数を公開します。フォーム ストアを渡すと初期入力へリセットできます。

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

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

// Reset to a fresh set of initial values
reset(form, { initialInput: { title: "", description: "" } })
 
// Sync the baseline to new server data, but keep the user's edits
reset(form, { initialInput: serverData, keepInput: true })

配列フィールド

Formisch は動的な配列フィールドを管理するための <FieldArray /> コンポーネントとヘルパー関数群を提供します。項目の追加、削除、並べ替えが必要な場合に使用します。

コンポーネント form-formisch-array が見つかりません。

FieldArray を使う

<FieldArray /> は <Field /> と同じ render prop パターンに従います。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>
  )
}

配列フィールドの構造

配列フィールドを <FieldLegend /> と <FieldDescription /> を持つ <FieldSet /> でラップします。

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>

項目を削除する

at インデックスを指定した remove 関数を使って配列から項目を削除します。

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 は項目の並べ替えと置換のために move、swap、replace も公開します。これらも同じ (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.")
  ),
})