0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

App Router の Server Actions に FormData 経由でネストしたオブジェクトを渡す方法

0
Posted at

背景

FormData からユーザーの入力値をオブジェクトとして受け取る方法

Next.js App Router におけるフォーム実装は、クライアントサイドにおいては required 等のHTML要素の標準属性を利用しつつ、useActionState を利用してサーバーサイド(Server Actions)で従来のようなバリデーションを実行し、クライアントはサーバーから受け取った結果を表示するだけにするという方針になっている。
参考: How to create forms with Server Actions

この時、シンプルなデータ構造であれば下記のようにform内にネストしたinput要素へnameを与えることで、これをkeyとしたオブジェクトを簡単に取得することができた。

const Form = () => {
  const createUser = async (state: SomeState, formData: FormData) => {
    "use server";
    const values = Object.fromEntries(formData.entries());

    console.log(values); // {"email":"xxx"} のような値が得られる

    return state;
  };

  const [, dispatch, isPending] = useActionState(createUser, initialState);

  return (
    <form action={dispatch}>
      <input name="email" type="text" />
      <button type="submit" disabled={isPending}>送信</button>
    </form>
  );
};

ユーザーの入力情報がネストされたオブジェクトや配列である場合

しかし、ユーザーの入力値をネストされたオブジェクトや配列として解釈したい場合もある。この時、シンプルに下記のように実装してしまうと、Server Actions上でデータを正常に取得できない。

const Form = () => {
  const createUser = async (state: SomeState, formData: FormData) => {
    "use server";
    const values = Object.fromEntries(formData.entries());

    console.log(values); // {"urls":"xxx"} のように、最後のinput要素に入力された値に上書きされてしまう

    return state;
  };

  const [, dispatch, isPending] = useActionState(createUser, initialState);

  return (
    <form action={dispatch}>
      <input name="urls" type="text" />
      <input name="urls" type="text" />
      <button type="submit" disabled={isPending}>送信</button>
    </form>
  );
};

FormDataは同じnameのinput要素が複数ある場合、FormDataオブジェクト内に複数の値を保持はしてくれるものの、それを formData.entries() によって出力しようとすると最後のinput要素の値で上書きしてしまう。
一応、下記のように getAll() を利用すれば複数の値を配列として受け取ることはできる。

const createUser = async (state: SomeState, formData: FormData) => {
  "use server";
  const values = {
    email: formData.get("email"),
    urls: formData.getAll("urls"),
  };

  console.log(values); // {"email":"xxx", "urls":["xxx", "yyy"]} のように取得できる

  return state;
};

しかし、フォームごとにユーザー入力情報の全てのkeyとvalueのマッピングをこのように書いていくのは正直あまり効率的でない。
どんな形式のオブジェクトであっても(ネストされたオブジェクトや配列を含んでいても)汎用的にパースする手段はないだろうか。

クエリパラメータ形式を利用して qs でパースする

ところでJavaScriptが広く使われる前の時代において、HTMLのform要素にユーザーが入力した値をサーバーに送信する際、ファイル等を含まないシンプルな文字列情報の場合は Content-Type: application/x-www-form-urlencoded という形式でリクエストボディが記述されていた。
これは name=John&age=20 のような、URLのクエリパラメータに見られる形式だ。

そして、この記法の拡張で urls[]=xxx&urls[]=yyy のように、 [] を利用して配列やオブジェクトを表現する方法が広く使われていた。
例えば {"urls":["xxx", "yyy"], "company":{"name":"xxx",address:"yyy"}} のようなオブジェクトをこの記法で表現すると下記のようになる。

<form>
  <input name="urls[0]" type="text" />
  <input name="urls[1]" type="text" />

  <input name="company[name]" type="text" />
  <input name="company[address]" type="text" />
</form>

この方法はURL Standardで公式に規定されたものではなく、現場の必要性から生まれて様々なWebサイトに広く普及してしまったらしく、URL Standardの公式ページを見ると下記のような苦渋の文章が書かれている。

注記: application/x-www-form-urlencoded 形式は、何年にもわたる実装の不幸な巡り合わせの結果,多くの面で奇異なものになっており、相互運用能を得るために必要とされる要件群からなる,妥協の産物である — 良い設計の実施を表現する仕方は無い。読者は特に、文字符号化法とバイト列との間で繰り返される(場合によっては入れ子にされた)変換のひねくれた詳細に,注意を払うように。あいにく,この形式は HTML フォームに普及しているがため、広く利用されている。

参考: URL Standard(日本語訳) - application/x-www-form-urlencoded

(古き良きテンプレートレンダリング時代のRuby on Railsでも、formを自動生成するDSLを使うとこの記法に従ったinput要素が生成された。)

経緯はどうあれ、広く使われているプラクティスであるが故に、この記法に対応したライブラリも存在する。
npm でいうと qs というライブラリが広く使われているため、今回はこれを利用していく。

const Form = () => {
  const createUser = async (state: SomeState, formData: FormData) => {
    "use server";
    const raw = formData
      .entries()
      .toArray()
      .map(([key, value]) => [key, String(value)]);
    const searchParams = new URLSearchParams(raw);
    const values = qs.parse(searchParams.toString());

    console.log(values); // {"urls":["xxx","yyy"],"company":{"name":"xxx","address":"yyy"}} のような期待通りの値を得られる

    return state;
  };

  const [, dispatch, isPending] = useActionState(createUser, initialState);

  return (
    <form action={dispatch}>
      <input name="urls[0]" type="text" />
      <input name="urls[1]" type="text" />

      <input name="company[name]" type="text" />
      <input name="company[address]" type="text" />

      <button type="submit" disabled={isPending}>
        送信
      </button>
    </form>
  );
};

:tada: こうしてユーザーの入力情報をネストしたオブジェクトや配列を含む値として解釈する方法が得られた :tada:

Zod を利用したバリデーション結果をクライアントサイドで表示する

オブジェクトの形式を自由に指定できるようになったところで、実務的には次にもう1つ問題が発生する。
通常、Webアプリケーションではユーザーの入力値にバリデーションエラーがあった場合、各入力欄の真下など対応する場所にエラーメッセージを表示することが多い。

しかし、Next.js公式のサンプルコードで使われている validatedFields.error.flatten().fieldErrors という値は、どの入力欄に対応するエラーなのかを返してくれない。

const createUser = async (state: SomeState, formData: FormData) => {
  "use server";
  const validatedFields = schema.safeParse(values)

  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      // ↑ {"urls": ["URLは必須です。"]} のような値が返り、何番目の入力欄に問題があるのか分からない
    }
  }

  return state;
};

そこで、ネストしたオブジェクトや配列でも「どのオブジェクトのどのプロパティに問題があるのか」「配列の何番目に問題があるのか」という情報とセットでバリデーション結果を返すように少し修正してみよう。

const userSchema = z.object({
  urls: z.array(z.string()),
  company: z.object({
    name: z.string(),
    address: z.string(),
  }),
});

const createUser = async (state: SomeState, formData: FormData) => {
  "use server";
  const raw = formData
    .entries()
    .toArray()
    .map(([key, value]) => [key, String(value)]);
  const searchParams = new URLSearchParams(raw);
  const values = qs.parse(searchParams.toString());

  const result = userSchema.safeParse(values);

  // ここからバリデーション結果の整形
  if (!result.success) {
    const fieldErrors: Record<string, string[]> = {};

    for (const error of result.error.errors) {
      // 例: ["users", 0, "name"] -> "users[0][name]" のように整形する
      const path = error.path
        .map((path, i) => (i === 0 ? `${path}` : `[${path}]`))
        .join("");
      if (!fieldErrors[path]) {
        fieldErrors[path] = [];
      }
      fieldErrors[path].push(error.message);
    }

    return {
      success: false,
      errors: fieldErrors, // {"urls[0]":["URLは必須です。"],"company[address]":["住所は必須です。"]} のようなオブジェクトが返る
      values,
    };
  }

  return { success: true, errors: null, values };
};

こうすると、ネストしたオブジェクトの配列のような位置も {"users[1][name]":["氏名は必須です。"]} のようにkeyで位置を表現されたオブジェクトが得られる。(1つの入力欄に対するエラーメッセージは複数ありうるので値は配列にしている。)
このエラーオブジェクトをもとに、適切な場所にエラーメッセージを表示するサンプルを最後に載せておく。

const Form = () => {
  const [{ values, errors }, dispatch, isPending] = useActionState(createUser, initialState);

  return (
    <form action={dispatch}>
      <input name="urls[0]" type="text" aria-invalid={!!errors?.["urls[0]"]} defaultValue={values["urls[0]"]} />
      {errors?.["urls[0]"]?.map((message) => (
        <p key={message}>{message}</p>
      ))}
      <input name="urls[1]" type="text" aria-invalid={!!errors?.["urls[1]"]} defaultValue={values["urls[1]"]} />
      {errors?.["urls[1]"]?.map((message) => (
        <p key={message}>{message}</p>
      ))}

      <input name="company[name]" type="text" aria-invalid={!!errors?.["company[name]"]} defaultValue={values["company[name]"]} />
      {errors?.["company[name]"]?.map((message) => (
        <p key={message}>{message}</p>
      ))}
      <input name="company[address]" type="text" aria-invalid={!!errors?.["company[address]"]} defaultValue={values["company[address]"]} />
      {errors?.["company[address]"]?.map((message) => (
        <p key={message}>{message}</p>
      ))}

      <button type="submit" disabled={isPending}>
        送信
      </button>
    </form>
  );
};

formのaction要素からuseActionStateのdispatchを実行するとユーザーの入力値がリセットされてしまうのでdefaultValueを渡している。

まとめ

以上で、useActionStateを利用して下記の要件を満たすサンプルコードを作ることができた。

  • FormDataに格納されたデータを、ネストされたオブジェクトや配列を含む値へ汎用的に変換する
  • Zodを利用してバリデーションを行い、その結果をどの位置の入力値に問題があるかの情報とともに返す
  • バリデーションエラーのメッセージを、該当する入力欄のすぐそばに表示する

この実装を実務でも利用しようとする場合、下記のような残課題についてもう少し工夫を凝らす余地はある。

  • Zodのスキーマとユーザー入力値を受け取ってバリデーション結果を整形して返す処理を抽象化する
  • formや入力欄・エラーメッセージ表示部分を共通パーツ化し、より少ない記述で各種フォームから汎用的に利用できるようにし、統一されたユーザー体験を保証する
  • エラーオブジェクトの型をreact-hook-formのようにvaluesの型に対応した形で型チェックが機能するようにする

とはいうものの、概ねここまでの内容でuseActionState + Server Actionsを利用したオーソドックスな実装方法を示すことができたのではないだろうか。
実務ではまだまだ使い始めたばかりで知らないことも多いと思うので、過不足やもっと良い工夫などがあればご意見を賜りたい。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?