TanStack Form

TanStack Form と Zod を使った React フォーム構築。

このガイドでは、TanStack Form を使ったフォーム構築を解説します。<Field /> コンポーネントでフォームを作成し、Zod でスキーマ検証を行い、エラー処理とアクセシビリティを実装する方法を学べます。

デモ

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

Component form-tanstack-demo not found in registry.

アプローチ

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

  • フォーム状態管理に TanStack Form の useForm フック を使用します。
  • 制御された入力のために form.Field コンポーネントのレンダープロップパターンを使用します。
  • アクセシブルなフォーム構築のために <Field /> コンポーネントを使用します。
  • Zod によるクライアントサイド検証を実装します。
  • リアルタイムな検証フィードバックを提供します。

アナトミー

<Field /> コンポーネントと TanStack Form を使ったフォームの基本的な例。

<form
  onSubmit={(e) => {
    e.preventDefault()
    form.handleSubmit()
  }}
>
  <FieldGroup>
    <form.Field
      name="title"
      children={(field) => {
        const isInvalid =
          field.state.meta.isTouched && !field.state.meta.isValid
        return (
          <Field data-invalid={isInvalid}>
            <FieldLabel htmlFor={field.name}>Bug Title</FieldLabel>
            <Input
              id={field.name}
              name={field.name}
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(e) => field.handleChange(e.target.value)}
              aria-invalid={isInvalid}
              placeholder="Login button not working on mobile"
              autoComplete="off"
            />
            <FieldDescription>
              Provide a concise title for your bug report.
            </FieldDescription>
            {isInvalid && <FieldError errors={field.state.meta.errors} />}
          </Field>
        )
      }}
    />
  </FieldGroup>
  <Button type="submit">Submit</Button>
</form>

フォーム

スキーマの作成

Zod スキーマでフォームの形式を定義します。

form.tsx
import * as z from "zod"
 
const formSchema = z.object({
  title: z
    .string()
    .min(5, "Bug title must be at least 5 characters.")
    .max(32, "Bug title must be at most 32 characters."),
  description: z
    .string()
    .min(20, "Description must be at least 20 characters.")
    .max(100, "Description must be at most 100 characters."),
})

フォームのセットアップ

TanStack Form の useForm フックで Zod 検証付きのフォームインスタンスを作成します。

form.tsx
import { useForm } from "@tanstack/react-form"
import { toast } from "sonner"
import * as z from "zod"
 
const formSchema = z.object({
  // ...
})
 
export function BugReportForm() {
  const form = useForm({
    defaultValues: {
      title: "",
      description: "",
    },
    validators: {
      onSubmit: formSchema,
    },
    onSubmit: async ({ value }) => {
      toast.success("Form submitted successfully")
    },
  })
 
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        form.handleSubmit()
      }}
    >
      {/* ... */}
    </form>
  )
}

この例では onSubmit でフォームデータを検証しています。TanStack Form は他の検証モードもサポートしています。詳細は documentation を参照してください。

フォームの構築

TanStack Form の form.Field コンポーネントと <Field /> コンポーネントでフォームを作成できます。

完成

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

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

検証

クライアントサイド検証

TanStack Form は Zod スキーマでフォームデータを検証します。ユーザーが入力するたびにリアルタイムで検証が行われます。

form.tsx
import { useForm } from "@tanstack/react-form"
 
const formSchema = z.object({
  // ...
})
 
export function BugReportForm() {
  const form = useForm({
    defaultValues: {
      title: "",
      description: "",
    },
    validators: {
      onSubmit: formSchema,
    },
    onSubmit: async ({ value }) => {
      console.log(value)
    },
  })
 
  return <form onSubmit={/* ... */}>{/* ... */}</form>
}

検証モード

TanStack Form は validators オプションで複数の検証ストラテジーをサポートしています。

モード説明
"onChange"変更ごとに検証が実行されます。
"onBlur"blur 時に検証が実行されます。
"onSubmit"送信時に検証が実行されます。
form.tsx
const form = useForm({
  defaultValues: {
    title: "",
    description: "",
  },
  validators: {
    onSubmit: formSchema,
    onChange: formSchema,
    onBlur: formSchema,
  },
})

エラーの表示

<FieldError /> でフィールドの横にエラーを表示します。スタイリングとアクセシビリティのため:

  • <Field /> コンポーネントに data-invalid を付けます。
  • <Input /><SelectTrigger /><Checkbox /> などのフォームコントロールに aria-invalid を付けます。
form.tsx
<form.Field
  name="email"
  children={(field) => {
    const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid
 
    return (
      <Field data-invalid={isInvalid}>
        <FieldLabel htmlFor={field.name}>Email</FieldLabel>
        <Input
          id={field.name}
          name={field.name}
          value={field.state.value}
          onBlur={field.handleBlur}
          onChange={(e) => field.handleChange(e.target.value)}
          type="email"
          aria-invalid={isInvalid}
        />
        {isInvalid && <FieldError errors={field.state.meta.errors} />}
      </Field>
    )
  }}
/>

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

Input

  • 入力フィールドには <Input />field.state.valuefield.handleChange を使用します。
  • エラー表示には <Input />aria-invalid<Field />data-invalid を付けます。

Component form-tanstack-input not found in registry.

form.tsx
<form.Field
  name="username"
  children={(field) => {
    const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid
    return (
      <Field data-invalid={isInvalid}>
        <FieldLabel htmlFor="form-tanstack-input-username">Username</FieldLabel>
        <Input
          id="form-tanstack-input-username"
          name={field.name}
          value={field.state.value}
          onBlur={field.handleBlur}
          onChange={(e) => field.handleChange(e.target.value)}
          aria-invalid={isInvalid}
          placeholder="tyapme"
          autoComplete="username"
        />
        <FieldDescription>
          This is your public display name. Must be between 3 and 10 characters.
          Must only contain letters, numbers, and underscores.
        </FieldDescription>
        {isInvalid && <FieldError errors={field.state.meta.errors} />}
      </Field>
    )
  }}
/>

Textarea

  • テキストエリアフィールドには <Textarea />field.state.valuefield.handleChange を使用します。
  • エラー表示には <Textarea />aria-invalid<Field />data-invalid を付けます。

Component form-tanstack-textarea not found in registry.

form.tsx
<form.Field
  name="about"
  children={(field) => {
    const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid
    return (
      <Field data-invalid={isInvalid}>
        <FieldLabel htmlFor="form-tanstack-textarea-about">
          More about you
        </FieldLabel>
        <Textarea
          id="form-tanstack-textarea-about"
          name={field.name}
          value={field.state.value}
          onBlur={field.handleBlur}
          onChange={(e) => field.handleChange(e.target.value)}
          aria-invalid={isInvalid}
          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>
        {isInvalid && <FieldError errors={field.state.meta.errors} />}
      </Field>
    )
  }}
/>

Select

  • Select コンポーネントには <Select />field.state.valuefield.handleChange を使用します。
  • エラー表示には <SelectTrigger />aria-invalid<Field />data-invalid を付けます。

Component form-tanstack-select not found in registry.

form.tsx
<form.Field
  name="language"
  children={(field) => {
    const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid
    return (
      <Field orientation="responsive" data-invalid={isInvalid}>
        <FieldContent>
          <FieldLabel htmlFor="form-tanstack-select-language">
            Spoken Language
          </FieldLabel>
          <FieldDescription>
            For best results, select the language you speak.
          </FieldDescription>
          {isInvalid && <FieldError errors={field.state.meta.errors} />}
        </FieldContent>
        <Select
          name={field.name}
          value={field.state.value}
          onValueChange={field.handleChange}
        >
          <SelectTrigger
            id="form-tanstack-select-language"
            aria-invalid={isInvalid}
            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>
    )
  }}
/>

Checkbox

  • チェックボックスには <Checkbox />field.state.valuefield.handleChange を使用します。
  • エラー表示には <Checkbox />aria-invalid<Field />data-invalid を付けます。
  • チェックボックス配列には <form.Field />mode="array" を使用し、TanStack Form の配列ヘルパーを利用します。
  • 適切なスタイリングと間距のため、<FieldGroup />data-slot="checkbox-group" を付けてください。

Component form-tanstack-checkbox not found in registry.

form.tsx
<form.Field
  name="tasks"
  mode="array"
  children={(field) => {
    const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid
    return (
      <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={isInvalid}
            >
              <Checkbox
                id={`form-tanstack-checkbox-${task.id}`}
                name={field.name}
                aria-invalid={isInvalid}
                checked={field.state.value.includes(task.id)}
                onCheckedChange={(checked) => {
                  if (checked) {
                    field.pushValue(task.id)
                  } else {
                    const index = field.state.value.indexOf(task.id)
                    if (index > -1) {
                      field.removeValue(index)
                    }
                  }
                }}
              />
              <FieldLabel
                htmlFor={`form-tanstack-checkbox-${task.id}`}
                className="font-normal"
              >
                {task.label}
              </FieldLabel>
            </Field>
          ))}
        </FieldGroup>
        {isInvalid && <FieldError errors={field.state.meta.errors} />}
      </FieldSet>
    )
  }}
/>

Radio Group

  • ラジオグループには <RadioGroup />field.state.valuefield.handleChange を使用します。
  • エラー表示には <RadioGroupItem />aria-invalid<Field />data-invalid を付けます。

Component form-tanstack-radiogroup not found in registry.

form.tsx
<form.Field
  name="plan"
  children={(field) => {
    const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid
    return (
      <FieldSet>
        <FieldLegend>Plan</FieldLegend>
        <FieldDescription>
          You can upgrade or downgrade your plan at any time.
        </FieldDescription>
        <RadioGroup
          name={field.name}
          value={field.state.value}
          onValueChange={field.handleChange}
        >
          {plans.map((plan) => (
            <FieldLabel
              key={plan.id}
              htmlFor={`form-tanstack-radiogroup-${plan.id}`}
            >
              <Field orientation="horizontal" data-invalid={isInvalid}>
                <FieldContent>
                  <FieldTitle>{plan.title}</FieldTitle>
                  <FieldDescription>{plan.description}</FieldDescription>
                </FieldContent>
                <RadioGroupItem
                  value={plan.id}
                  id={`form-tanstack-radiogroup-${plan.id}`}
                  aria-invalid={isInvalid}
                />
              </Field>
            </FieldLabel>
          ))}
        </RadioGroup>
        {isInvalid && <FieldError errors={field.state.meta.errors} />}
      </FieldSet>
    )
  }}
/>

Switch

  • スイッチには <Switch />field.state.valuefield.handleChange を使用します。
  • エラー表示には <Switch />aria-invalid<Field />data-invalid を付けます。

Component form-tanstack-switch not found in registry.

form.tsx
<form.Field
  name="twoFactor"
  children={(field) => {
    const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid
    return (
      <Field orientation="horizontal" data-invalid={isInvalid}>
        <FieldContent>
          <FieldLabel htmlFor="form-tanstack-switch-twoFactor">
            Multi-factor authentication
          </FieldLabel>
          <FieldDescription>
            Enable multi-factor authentication to secure your account.
          </FieldDescription>
          {isInvalid && <FieldError errors={field.state.meta.errors} />}
        </FieldContent>
        <Switch
          id="form-tanstack-switch-twoFactor"
          name={field.name}
          checked={field.state.value}
          onCheckedChange={field.handleChange}
          aria-invalid={isInvalid}
        />
      </Field>
    )
  }}
/>

Complex Forms

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

Component form-tanstack-complex not found in registry.

フォームのリセット

form.reset() でフォームをデフォルト値にリセットします。

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

配列フィールド

TanStack Form は mode="array" による強力な配列フィールド管理を提供します。完全な検証サポート付きで、配列アイテムの動的な追加・削除・更新が可能です。

Component form-tanstack-array not found in registry.

この例では、配列フィールドを使った複数メールアドレスの管理を示します。最大5件のメールアドレスを追加でき、個別のアドレスを削除でき、各アドレスは個別に検証されます。

配列フィールドの構造

親フィールドに mode="array" を付けて配列フィールド管理を有効にします。

form.tsx
<form.Field
  name="emails"
  mode="array"
  children={(field) => {
    return (
      <FieldSet>
        <FieldLegend variant="label">Email Addresses</FieldLegend>
        <FieldDescription>
          Add up to 5 email addresses where we can contact you.
        </FieldDescription>
        <FieldGroup>
          {field.state.value.map((_, index) => (
            // Nested field for each array item
          ))}
        </FieldGroup>
      </FieldSet>
    )
  }}
/>

ネストされたフィールド

fieldName[index].propertyName のブラケット表記で個別配列アイテムにアクセスします。この例では InputGroup で削除ボタンを入力エリア内にインライン表示します。

form.tsx
<form.Field
  name={`emails[${index}].address`}
  children={(subField) => {
    const isSubFieldInvalid =
      subField.state.meta.isTouched && !subField.state.meta.isValid
    return (
      <Field orientation="horizontal" data-invalid={isSubFieldInvalid}>
        <FieldContent>
          <InputGroup>
            <InputGroupInput
              id={`form-tanstack-array-email-${index}`}
              name={subField.name}
              value={subField.state.value}
              onBlur={subField.handleBlur}
              onChange={(e) => subField.handleChange(e.target.value)}
              aria-invalid={isSubFieldInvalid}
              placeholder="name@example.com"
              type="email"
            />
            {field.state.value.length > 1 && (
              <InputGroupAddon align="inline-end">
                <InputGroupButton
                  type="button"
                  variant="ghost"
                  size="icon-xs"
                  onClick={() => field.removeValue(index)}
                  aria-label={`Remove email ${index + 1}`}
                >
                  <XIcon />
                </InputGroupButton>
              </InputGroupAddon>
            )}
          </InputGroup>
          {isSubFieldInvalid && (
            <FieldError errors={subField.state.meta.errors} />
          )}
        </FieldContent>
      </Field>
    )
  }}
/>

アイテムの追加

field.pushValue(item) で配列フィールドにアイテムを追加します。配列が最大長に達したときにボタンを無効化できます。

form.tsx
<Button
  type="button"
  variant="outline"
  size="sm"
  onClick={() => field.pushValue({ address: "" })}
  disabled={field.state.value.length >= 5}
>
  Add Email Address
</Button>

アイテムの削除

field.removeValue(index) で配列フィールドからアイテムを削除します。アイテムが2件以上のときのみ削除ボタンを表示するようにできます。

form.tsx
{
  field.state.value.length > 1 && (
    <InputGroupButton
      onClick={() => field.removeValue(index)}
      aria-label={`Remove email ${index + 1}`}
    >
      <XIcon />
    </InputGroupButton>
  )
}

配列の検証

Zod の配列メソッドで配列フィールドを検証します。

form.tsx
const formSchema = z.object({
  emails: z
    .array(
      z.object({
        address: z.string().email("Enter a valid email address."),
      })
    )
    .min(1, "Add at least one email address.")
    .max(5, "You can add up to 5 email addresses."),
})