🪵

Next.jsで素朴なフォームをシンプルに作る

2024/03/08に公開

素朴なフォームを作る

この記事では、素朴なフォームをNext.jsを使って簡単に(?)作る方法を順を追って解説します。これを読むことでNext.js AppRouterの新しい機能であるServer ActionsやuseFormStateなどの使い方理解が進むはずです。たぶん。

また、今回解説するServer Actionsを中心とした機能を用いれば、従来Reactでフォームを作る時のフロントエンドとバックエンド処理が煩雑になりがちなところを少しはシンプルに作ることができるようなります。ただし「素朴なフォーム」という前提ですが。

前提

  • Next.js v14.1
  • Next.js以外の外部ライブラリは使用していません

HTMLのモックから

まず素朴なフォームをHTMLから組み立てます。これ以上ないくらい素朴ですね。


素朴だ…(CSSのスタイル情報は記事上では消してます)

src/app/simple/page.tsx
export default function Page() {
  return (
    <form>
      <input type="text" name="name" />
      <button type="submit">送信</button>
    </form>
  );
}

まずはここから始めましょう。

フォームからPOSTする

従来であればWebAPIを用意し、handleSubmitしたデータをfetchでPOSTをするというのが一般的な作り方でしたが、今回はServer Actionsを使います。

Server ActionsはReactで実験的に提供されている機能で、Next.jsでは先行して提供されているものになります。その名の通り、バックエンドで実行される関数をフロントエンドから呼び出すことができます。

呼び出すと言っても実際にはHTTPリクエストをしているため、従来のAjaxと同様の挙動です。ただし、大きな違いはソースコード上で、直接バックエンドの関数を呼び出すようにかけることです。

それではServer Actionsを使ってフォームのデータをPOSTします。まずはServer Actionsの関数定義からです。

src/app/simple/postAction.ts
"use server";

export async function postAction(formData: FormData) {
  const name = formData.get("name");
  console.log(name);
}

Server Actionsと言ってもごく普通の関数です。特徴は "use server"; でバックエンド側であることを宣言すること。そして非同期処理である async であること、引数に FormData オブジェクトがわたってくることです。

続いてServer Actionsをフォームから呼び出すように修正します。

src/app/simple/page.tsx
+ import { postAction } from "@/app/simple/postAction";

export default function Page() {
  return (
+    <form action={postAction}>
      <input type="text" name="name" />
      <button type="submit">送信</button>
    </form>
  );
}

これだけで「送信」ボタンを押すと postAction 関数が実行されます。ログはバックエンド側のターミナルに表示されるはずです。

あとはServer Actionsの関数内で必要な処理をすれば最低限のフォームはできます。

src/app/simple/postAction.ts
"use server";

export async function postAction(formData: FormData) {
  const name = formData.get("name");
+  // なんらかのデータ処理をする
}

Thanksページに遷移させる

さてPOSTすることはできましたが、POSTをしたら、サンクスページにリダイレクトさせたくなります。これは単純にServer Actions内で redirect() させるだけです。

先にリダイレクト先のページを適当に作っておきます。

src/app/simple/thanks/page.tsx
import Link from "next/link";

export default function Page() {
  return (
    <div>
      投稿ありがとうございます。
      <Link href="/simple">フォームに戻る</Link>
    </div>
  );
}

あとは next/navigation が提供する redirect() を呼び出すだけで完了です。

src/app/simple/postAction.ts
"use //server";
+ import { redirect } from "next/navigation";

export async function postAction(formData: FormData) {
  const name = formData.get("name");
  // なんらかのデータ処理をする
+  redirect("/simple/thanks");
}

バリデーションをサーバ側でする

フォームをPOSTすることはできましたが、重要なバリデーションの実装がまだです。バリデーションにはZodやValibotなどのライブラリを使うのをオススメしますが、この記事では説明をシンプルにするために自前でチェックします。

また、バリデーションはバックエンド側だけで行います。素朴なフォームなのでフロントエンドのリアルタイムバリデーションは実装しないこととします。

src/app/simple/postAction.ts
"use server";

import { redirect } from "next/navigation";

export async function postAction(formData: FormData) {
  const name = formData.get("name");
+  if (!name) {
+    return {
+      errors: {
+        name: "名前を入力してください",
+      },
+    };
+  }

  redirect("/simple/thanks");
}

もし name の値がなければエラーメッセージを返すようにしています。しかしこのServer Actionsの返り値を受け取って画面にエラーメッセージを表示するにはどうすればいいでしょうか。いくつかやり方はありますが、今回はReactが提供する useFormState というフックを使います。

useFormState はServer Actionsの返り値をリアクティブに扱えるようにしてくれるもので、useState のServer Actions対応版のようなものだと思ってもらえればイメージが付きやすいかもしれません。

src/app/simple/thanks/page.tsx
+"use client";

+import { useFormState } from "react-dom";
import { postAction } from "@/app/simple/postAction";

export default function Page() {
+  const [result, dispatch] = useFormState(postAction, {});
  return (
+    <form action={dispatch}>
+      {result.errors && <div>{result.errors.name}</div>}
      <input type="text" name="name" />
      <button type="submit">Submit</button>
    </form>
  );
}

useFormStateuseState と同じくクライアントコンポーネントでないと使えないので、 "use client"; で境界線を宣言します。

そして useFormState にはServer Actions関数とステートの初期値を渡すと、Server Actions関数代わりのdispatch(名前は何でもいいです)が返ってきます。それを action 属性に置き換えます。

最後に、Server Actions関数の引数を以下のように変更します。

src/app/simple/postAction.ts
-export async function postAction(formData: FormData) {
+export async function postAction(prev: any, formData: FormData) {

useFormState を通して実行すると、第一引数には前回実行されたServer Actionsの返り値が自動で入ってくるようになります。今回は特に利用しません。

これでバリデーションの実装は以上です。フォームを未入力のままSubmitすると、エラーメッセージが画面に表示されると思います。このやり方であれば、入力欄ごとにエラーメッセージを個別表示させたりなど柔軟な状態管理が useFormState だけで実現できます。


エラーメッセージが表示されている様子

送信ボタンを送信中に変える

続いてフォームを送信中に連続クリックなどで二重に送信されないようにボタンの制御をします。これには useFormStatus を使います。useFormState と字面が似ているので紛らわしいですね。

これは対象のフォームが送信中かどうかの状態を status.pending で受け取ることができます。これを使って、ボタンの disabled 属性を付けたり、ラベルを「送信中」にしたりを制御できます。

src/app/simple/thanks/page.tsx
"use client";

+import { useFormState, useFormStatus } from "react-dom";
import { postAction } from "@/app/simple/postAction";

+function Submit() {
+  const status = useFormStatus();
+  return (
+    <button type="submit" disabled={status.pending}>
+      {status.pending ? "送信中..." : "送信"}
+    </button>
+  );
+}

export default function Page() {
  const [result, dispatch] = useFormState(postAction, {});
  return (
    <form action={dispatch}>
      {result.errors && <div>{result.errors.name}</div>}
      <input type="text" name="name" />
+      <Submit />
    </form>
  );
}


送信中にボタンが無効になっている様子

注意点としては、useFormStatus を使うボタンは別コンポーネントに切り出す必要があります。つまり以下のようにフォームの中にそのままおいてあっても機能しません。あくまで親コンポーネントのフォーム状態を対象にするようになっています。

src/app/simple/thanks/page.tsx
export default function Page() {
  const [result, dispatch] = useFormState(postAction, {});
  const status = useFormStatus();
  return (
    <form action={dispatch}>
      {result.errors && <div>{result.errors.name}</div>}
      <input type="text" name="name" />
      <button type="submit" disabled={status.pending}>
        {status.pending ? "(切り替わらない)送信中..." : "送信"}
      </button>
    </form>
  );
}

以上で、Next.jsを使って素朴なフォームをシンプルに作るための方法を解説しました。今どきのリッチなフォームとは異なるため物足りなさを感じるかもしれませんが、これをベースに必要な機能を肉づけていくのが良いでしょう。

コード全体

さいごにこの記事で作ったコードの全部を掲載しておきます。

src/app/simple/thanks/page.tsx
"use client";

import { useFormState, useFormStatus } from "react-dom";
import { postAction } from "@/app/simple/postAction";

function Submit() {
  const status = useFormStatus();
  return (
    <button type="submit" disabled={status.pending}>
      {status.pending ? "送信中..." : "送信"}
    </button>
  );
}

export default function Page() {
  const [result, dispatch] = useFormState(postAction, {});
  const status = useFormStatus();
  return (
    <form action={dispatch}>
      {result.errors && <div>{result.errors.name}</div>}
      <input type="text" name="name" defaultValue="default user" />
      <Submit />
    </form>
  );
}
src/app/simple/postAction.ts
"use server";

import { redirect } from "next/navigation";

interface ReturnType {
  errors?: {
    name?: string;
  };
}

export async function postAction(
  prevData: ReturnType,
  formData: FormData,
): Promise<ReturnType> {
  const name = formData.get("name");
  if (!name) {
    return {
      errors: {
        name: "名前を入力してください",
      },
    };
  }

  redirect("/simple/thanks");
}

おまけ

POST後にリダイレクトさせずにフォーム内容をクリアさせたい

POST後に <form key> 属性を変更することで強制的に再描画させる方法です。

<form action={dispatch} key={result?.key}>
export async function postAction() {
  return { key: 'reset' };
}

簡単に対応できますがちょっと力技感があり、個人的には好みではありません。useRef を使ってフォームリセットさせる方法もありますがこちらはこちらで handleSubmit 処理をして上げる必要があり面倒です。React側で手軽にできる方法が提供されると嬉しいですね。

ムーザルちゃんねる

Discussion