📝

Next.jsで親子関係あるいはネストされたフォームを作る

2024/03/11に公開1

Webアプリを開発していると、親子関係のモデル構造を、1つのフォームで一括で保存・更新する処理が頻繁に出てきますよね。例えばRailsでは accepts_nested_attributes_for を使ってhas-manyなフォームが簡単に作れたりします。

これをNext.jsでも親子関係あるいはネストされたフォームを作る方法を紹介します。ただし、この記事ではUIの構築ではなく、構造化されたデータを受け取るための処理についてだけ解説をします。

フォームを構造化するには?

具体的なやり方を解説する前に、そもそもRailsやPHPではどのようにしてフォームデータを構造化しているのかを紹介します。以下は、Railsが出力するフォームのHTMLです。Userが親で、Articleが子の関係性で、ユーザが複数の記事を持っています。

<form action="/users" method="post">
  <input type="text" name="user[name]">
  <input type="text" name="user[articles_attributes][0][title]">
  <input type="text" name="user[articles_attributes][1][title]">
</form>

注目してほしいのは name 属性です。 user[name] これはUserモデルの名前フィールドを指すキーです。次に user[articles_attributes][0][title] はUser配下のArticleモデルを配列として持ち、最初の記事の件名フィールドを指すキーになっています。

このように配列のインデックスや、PHPでいう連想配列のような記法を使ってフォームデータを構造化しています。

しかし、この仕様自体はHTMLやJavaScriptのFormDataAPIには全く関係がありません。user[articles_attributes][0][title] と構造を表現しても、HTML的には単なる文字列のキーとして扱われます。

これをRailsでは、Rackがフォームデータをパースして構造化するのを自動でやってくれているだけです。また、PHPでも同様に言語レベルでPOSTデータを構造化して $_POST で連想配列として扱えるようになっています。すごい便利ですね。

Node.jsの世界ではずっと使われているExpressも同様にパースすることができます。body-parserqs というライブラリがパース部分を担当しています。

Next.jsでフォームを構造化する

Next.jsでも同じ様にフォームを構造化して親子関係のデータを良い感じに受け取ってみましょう。Next.js AppRouterではフォームにはServer Actionsを利用すると楽なので今回もServer Actionsを使ってフォームを作ります。

先程の例と同じように作ってみましょう。 articles_attributes はちょっと長いので単純に articles というキーに変更します。

Page.tsx
import { submitAction } from "./submitAction";

export default function Page() {
  return (
    <>
      <form action={submitAction}>
        <input type="text" name="user[name]" />
        <input type="text" name="user[articles][0][title]" />
        <input type="text" name="user[articles][1][title]" />
        <button type="submit">Submit</button>
      </form>
    </>
  );
}

受け取ったFormDataをそのまま愚直に内容を出力してみます。

submitAction.ts
export async function submitAction(formData: FormData) {
  console.log(Object.fromEntries(formData.entries()));
}

すると以下のような構造になっています。当然ですがやはり 'user[articles][0][title]' は単なる文字列のキーになっていますね。

{
  "user[name]": "1",
  "user[articles][0][title]": "2",
  "user[articles][1][title]": "3"
}

これをパースして以下のように受け取りたいです。

{
  "user": {
    "name":"1",
    "articles":[
      {"title":"2"},
      {"title":"3"}
    ]
  }
}

構造化させるためにはいくつか方法はありますが、今回は2つのライブラリを紹介します。

qsを使う

1つ目は定番ライブラリ qs です。あらゆるパラメータをパースするのに昔から使われています。しかし、qsは直接FormDataを扱うことができません。

そこでqsがパースできるようにFormDataをURLSearchParamsを使って文字列に変換します。 user[name]=1&user[article][0][title]=2 のようなQueryString形式です。

import * as qs from "qs";

export async function submitAction(formData: FormData) {
  const formDataToQueryString = (formData: FormData) => {
    const params = new URLSearchParams();
    for (const [key, value] of formData) {
      params.append(key, value as string);
    }
    return params.toString();
  };

  const data = qs.parse(formDataToQueryString(formData));
}

こうすると構造化されたデータを受け取ることができます。

あとはZodやValibotのスキーマにこれをそのまま渡してあげることで、フォームデータを自前で編集せずにバリデーションをすることができて便利です。

const userSchema = z.object({
  name: z.string().min(1),
  articles: z.array(articleSchema),
});
const articleSchema = z.object({
  title: z.string().min(1),
});
userSchema.safeParse(data);

parse-nested-form-dataを使う

比較的新しいライブラリでparse-nested-form-dataというものがあります。これはqsとは異なりFormDataAPIをそのままパースできるようになっています。

以下のように直接FormDataのオブジェクトを渡すだけで構造化されたデータを受け取れます。

import { parseFormData } from "parse-nested-form-data";
export async function submitAction(formData: FormData) {
  const data = parseFormData(formData);
}

ただし、parse-nested-form-dataはRailsやPHPのような記法とはちょっと異なります。フィールド名は連想配列の [] ではなく、. のオブジェクトのような記法ルールになっています。

  • user[name] -> user.name
  • user[article][0][title] -> user.article[0].title

好みになるかもしれませんが、個人的には直接FormDataを扱えるほうが楽なのでparse-nested-form-dataを使っています。

また、FormDataで受け取れる値はすべて文字列になってしまうのですが、parse-nested-form-dataには型変換もサポートしているのでとても便利です。例えば &isDelete=true とキーの先頭に & があるとBooleanとして値を受け取れます。

という感じで、Next.jsでもちょっと複雑な親子関係・ネストされたフォームを作って、良い感じにデータを受け取ることができるようになりました。もし他に良い方法があればコメントで教えて下さい!

ムーザルちゃんねる

Discussion

Yuki MasakiYuki Masaki

うわ、めっちゃ神記事です❤️❤️
ネストされたフォーム作りたかったんです😭