@k8ordo/form

入れ子と繰り返し行

スキーマの中にあるオブジェクトは、ドットでつないだパスで扱います。オブジェクトの配列は、行の集まりとして扱います。どちらの場合も、パスがそのままブラウザの送るnameになります。

このページの内容

入れ子のオブジェクトの入力欄を使う

field()には、ドットでつないだパスを渡します。送信されるnameも、state.errorsのキーも同じパスです。

schema.ts
z.object({
  user: z.object({
    email: z.email(),
  }),
});
profile-form.tsx
const email = form.field('user.email');

<input {...email.input} />

.optional()や.default()の付いたオブジェクトでも、中の入力欄はそのまま使えます。

警告

落とし穴

オブジェクトを省ける場合でも、中の入力欄は必ず描いてください。名前がFormDataに無いと、parseFormはinputの展開し忘れとみなしてエラーを投げます。

行を足したり消したりする

オブジェクトの配列はarray()で扱います。行ごとの入力欄はrow.field()で取り出し、row.keyをkeyに渡します。

schema.ts
z.object({
  items: z
    .array(
      z.object({
        name: z.string().min(1),
        quantity: z.coerce.number().int().min(1),
      }),
    )
    .min(1)
    .max(3),
});
order-form.tsx
const items = form.array('items');

{items.rows.map((row) => (
  <fieldset key={row.key}>
    <input {...row.field('name').input} />
    <input {...row.field('quantity').input} />
    {items.canRemove && (
      <button onClick={row.remove} type="button">
        Remove
      </button>
    )}
  </fieldset>
))}
{items.canAdd && (
  <button onClick={items.add} type="button">
    Add a row
  </button>
)}

canAddとcanRemoveは、スキーマの.max()と.min()から決まります。サーバーが受け付けない行数になる手前で、ボタンが消えます。

Reactのstateが持つのは、行を見分けるためのkeyだけです。値はDOMにあるので、行を足しても消しても、入力された値をReactに写すことはありません。

Playground

注文の行を足す

1行から3行まで入力できるスキーマです。送信すると、送られるはずだった名前と値を表示します。

1行目

試してみる

  1. 「行を足す」を2回押すと、3行目でボタンが消えます。スキーマの.max(3)が効いています。
  2. 2行目を空のまま「送信」を押すと、送信が止まり、2行目の入力欄にフォーカスが移ります。
  3. 1行目を消してから残りを埋めて送ると、添字が詰まり、items[0].nameから並びます。

行数のエラーを表示する

行が.min()より少ない、または.max()より多いというエラーは、どの行の入力欄にも属しません。このエラーはitems.errorに入ります。

order-form.tsx
<form {...form.props} action={formAction}>
  {items.error !== undefined && (
    <p {...items.errorProps}>{items.error}</p>
  )}
  {items.rows.map((row) => (
    <fieldset key={row.key}>{/* fields */}</fieldset>
  ))}
</form>

表示する要素にはitems.errorPropsを展開します。展開しないと、配列だけが失敗した送信ではフォーカスがどこにも移らず、スクリーンリーダーにも何も伝わりません。

表示する場所は、行より上にします。行も失敗しているときでもまずここにフォーカスが移り、そこからTabキーで行へ進めます。

送られる名前

行の中の入力欄のnameは、items[0].nameのように添字を含みます。エラーのキーも同じ形です。

ts
row.field('name').input.name;
// 'items[0].name'

state.errors;
// { 'items[1].quantity': 'Order at least one' }

行を消すと、後ろの行の添字が1つずつ詰まります。ブラウザで出したエラーも、行と一緒に移ります。

警告

落とし穴

ただし、サーバーが返したエラーの添字は振り直されません。上の行を消すと、まだ直していないエラーは、その添字になった別の行に表示されます。

最初に描く行数

送信のあとならstate.rowsの行数、そうでなければスキーマの.min()の行数を描きます。.min()も無ければ0行です。

parseFormは届いた行数をstate.rowsに入れて返します。そのため、JavaScriptが無い状態で送り直しても、同じ行数で描き直せます。

行数は送られた添字から数えますが、.max()を超える数は信じません。大きな添字を偽って送られても、サーバーが大きな配列を作ることはありません。

文字列の配列

z.array(z.string())のような値の配列では、行の入力欄にキーがありません。row.field()を引数なしで呼ぶと、nameはtags[0]のようになります。

tags-form.tsx
const tags = form.array('tags');

{tags.rows.map((row) => (
  <input key={row.key} {...row.field().input} />
))}
情報

メモ

選択肢の配列(z.array(z.enum([…])))は、行ではなく1つの入力欄として扱うチェックボックスの集まりです。詳しくは「入力欄の種類」を見てください。

扱えない形

  • 行の中にさらに行を入れる形は、送信する名前が1つに決まらないため、formFieldsとparseFormがエラーを投げます。型の上では止められないので注意してください。
  • sameAsなどのルールは、行の中の入力欄には宣言できません。行の入力欄の検証は、スキーマの中に書きます。
k8ordo

Baselineに入った機能を、制限なく使うReactのライブラリ群。

© 2026 k8o — MIT License

組版:Noto Sans JP / M PLUS 2