🔼

Next.js で form を作る際のベストプラクティス

に公開

Next.js で form を作る際の今風な実装方法をまとめました。

server actions や useActionState フックを使うことで、少ないコード量でフォームを実装できます。

今回実装するフォーム

まずは完成品から。テキストを入力してボタンを押すと内容がサーバーに送信される単純なフォームです。フォーム送信中はボタンを非活性にします。

pages router 時代の実装方法

比較対象として、ひと昔前はどのようにフォームを実装していたか復習します。

公式ドキュメント (How to create forms with API Routes) に書かれている実装を引用します。

まず、送信されたフォームの内容を受け取る API を準備しなければなりません。

// pages/api/submit.ts
import type { NextApiRequest, NextApiResponse } from 'next'

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const data = req.body
  const id = await createItem(data)
  res.status(200).json({ id })
}

次にクライアントサイドの実装です。ボタンがクリックされた際、上記で準備した API にリクエストする処理を書く必要があります。さらに、フォーム側の状態を自前で管理する必要があり、ボイラープレート的なコードが量産されるのが欠点でした。

// pages/index.tsx

import React, { useState, FormEvent } from 'react'
 
export default function Page() {
  const [isLoading, setIsLoading] = useState<boolean>(false)
 
  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setIsLoading(true) // Set loading to true when the request starts
 
    try {
      const formData = new FormData(event.currentTarget)
      const response = await fetch('/api/submit', {
        method: 'POST',
        body: formData,
      })
 
      // Handle response if necessary
      const data = await response.json()
      // ...
    } catch (error) {
      // Handle error if necessary
      console.error(error)
    } finally {
      setIsLoading(false) // Set loading to false when the request completes
    }
  }
 
  return (
    <form onSubmit={onSubmit}>
      <input type="text" name="name" required />
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Loading...' : 'Submit'}
      </button>
    </form>
  )
}

app router 時代の実装方法

今は server actions を使うことで、よりシンプルにフォームを実装できます。

こちらは公式ドキュメント (How to create forms with Server Actions) を参考にしました。

サーバー側の処理は "use server" というディレクティブを使って定義します。エラー有無などフォームの状態管理はこちらの関数に寄せてしまうのがポイントです。

// app/actions.ts
"use server"

import { createItem } from "./repository"

export interface State {
  isError: boolean
  message: string
}

export async function submitForm(_: State, formData: FormData): Promise<State> {
  try {
    const name = formData.get("name")?.toString() ?? ""
    const id = await createItem({ name })
    return {
      isError: false,
      message: `Item created successfully (id=${id}).`,
    }
  } catch {
    return {
      isError: true,
      message: `Failed to create item.`,
    }
  }
}

次にクライアントサイドです。フォームの状態管理は useActionState フックを利用し、送信時に先ほど用意した関数が呼ばれるようにします。送信中状態 (isPending) もこのフックが自動で管理してくれるため、実装量がかなり減りました。

// app/page.tsx
"use client"

import { useActionState } from "react"
import { State, submitForm } from "./actions"

const initialState: State = {
  isError: false,
  message: "",
}

export default function Page() {
  const [state, formAction, isPending] = useActionState(submitForm, initialState)

  return (
    <form action={formAction}>
      <input type="text" name="name" required />
      <button type="submit" disabled={isPending}>
        {isPending ? "Loading..." : "Submit"}
      </button>
      <p style={{ color: state.isError ? "red" : "green" }}>{state.message}</p>
    </form>
  )
}

まるでサーバーサイドの処理をクライアントから関数として呼んでいるかのように書けるのが良いですね。ユーザーが送信ボタンを押すとブラウザからサーバーに fetch ベースのリクエストが送信されるのですが、実装者はそれを意識しなくて良い作りになっています。

注意点

上記の方法で実装できるのは、比較的要件が単純なフォームに限定されるとは思います。フォーム送信前にクライアント側でもバリデーションをしたり、よりインタラクティブな表示をしたりするのであれば、従来と同様 onSubmit を使うことになりそうです。ただし、その際も server actions を組み合わせることでシンプルな実装にできるのではないでしょうか?

Discussion