📝

React Server ComponentsだけでForm実装する

に公開

はじめに

Next.js(App Router)で入力フォームを実装する際に、私は今までずっとClient Componentsで実装していました。
useStateを使い、変更イベントを検知して、buttonのonClickで送信する。
シンプルにReactで実装しようとした場合、私は上記のような流れで実装を進めていました。

Next.js(App Router)使うならできるだけServer Componentsにしたいなと常々思っていたServer Components信者である私は、Client Componentを駆逐できないか調べて、その方法を見つけたので共有します。

結論

Web標準に立ち返る。
formを素のhtmlで実装するのと同じ要領でServer ComponentsとServer Actionsを組み合わせることでClient Componentsを駆逐することができます。
https://developer.mozilla.org/ja/docs/Web/HTML/Reference/Elements/form

きっかけ

Next.jsがv16にアップグレードしCache Componentsが登場したということでNext.jsのドキュメントを読んでいたところ、Formコンポーネントというものを見つけました。(Formコンポーネントはこの記事では扱いません)
https://nextjs.org/docs/app/api-reference/components/form
どうやらactionにServer Actionsを指定することができるらしい。あれ?でもどうやってServer Actionsに入力値を渡すのだろうか?
疑問を抱きました。

form

formにはmethodというattributeがあり、postの場合リクエストbodyとして送信されるようです。
https://developer.mozilla.org/ja/docs/Web/HTML/Reference/Elements/form#method

Server Actionsでリクエストbodyを受け取るI/FはFormDataになります。
https://developer.mozilla.org/ja/docs/Web/API/FormData

FormData内の各項目を取得するにはform内の要素につけたnameで取得できます。
https://developer.mozilla.org/ja/docs/Web/HTML/Reference/Elements/input#name

実装

パッケージ構成

| packege    | version|
| Next.js    | 15.5.7 |
| React      | 19.2.0 |
| TypeScript | 5.9.3  |
| Zod        | 4.1.13 |

ディレクトリ構成

app/
 ├ page.tsx
 ├ example-form.tsx
 └ actions.ts

実装例1

PageComponent

app/page.tsx

import { ExampleFormServerComponent } from "./example-form"

export default function Page() {
  return <ExampleFormServerComponent />
}

ExampleFormServerComponent

app/example-form.tsx

import { submitServerAction } from "./actions"

export function ExampleFormServerComponent() {
  return (
    <form action={submitServerAction}>
      <input name="email" type="email" />
      <button type="submit">送信</button>
    </form>
  )
}

submitServerAction

app/actions.ts

"use server"

export async function submitServerAction(formData: FormData) {
  const email = formData.get("email")
  console.log(email)
}

解説1

実装は至ってシンプルなメールアドレス入力フォームです。
formのアクションにServer Actionsを指定しています。

<form action={submit}>

Server Actionsの引数としてFormDataを受け取ります。

function submit(formData: FormData)

formData.get(項目名)で入力値を取得できます。

const email = formData.get("email")

項目名はinputのnameなのでemailになります。

<input name="email" type="email" />

たったこれだけでClient Componentsを使わずにServer Componentsだけで入力フォームが完成です。
こういう実装じゃなきゃいけないという先入観は怖いものですね。

バリデーションとエラーハンドリング

実装例1では何のバリデーションもエラーハンドリングもないので改修していきます。
基本的にはsubmitしてからバリデーションが走ります。

実装例2

app/page.tsx

import { ExampleFormServerComponent } from "./example-form"

interface Props {
  searchParams: Promise<{ error?: string; success?: string; email?: string }>
}

export default async function Page({ searchParams }: Props) {
  const { error, success, email } = await searchParams
  return (
    <ExampleFormServerComponent
      error={error}
      defaultEmail={email}
    />
  )
}

app/example-form.tsx

import { submitServerAction } from "./actions"

interface Props {
  error?: string
  defaultEmail?: string
}

export function ExampleFormServerComponent({
  error,
  defaultEmail,
}: Props) {
  return (
    <form action={submitServerAction}>
      <input name="email" type="email" defaultValue={defaultEmail} />
      {error && <p style={{ color: "red" }}>{error}</p>}
      <button type="submit">送信</button>
    </form>
  )
}

app/actions.ts

"use server"

import { redirect } from "next/navigation"
import { z } from "zod"

const schema = z.object({
  email: z.email({ error: "有効なメールアドレスを入力してください" }),
})

export async function submitServerAction(formData: FormData): Promise<void> {
  const result = schema.safeParse({
    email: formData.get("email"),
  })

  if (!result.success) {
    const errorMessage =
      result.error.issues[0]?.message ?? "バリデーションエラー"
    redirect(`/?error=${encodeURIComponent(errorMessage)}`)
  }

  console.log(result.data.email)
  redirect("/top")
}

解説2

上記の例は、メールアドレスのバリデーションでエラーになったら、クエリーパラメータでエラーメッセージを送り、入力フォームページで表示させるようにしています。
ただこの方法は、項目が多くなってくるとクエリーパラメータが長くなってしまう問題と入力値を保存できない問題があります。

実装例3

app/page.tsx

import { ExampleFormServerComponent } from "./example-form"

export default function Page() {
  return <ExampleFormServerComponent />
}

app/example-form.tsx

import { getFormState, submitServerAction } from "./actions"

export async function ExampleFormServerComponent() {
  const { values, errors } = await getFormState()

  return (
    <form action={submitServerAction}>
      <input name="email" type="email" defaultValue={values.email} />
      {errors?.email && <p style={{ color: "red" }}>{errors.email[0]}</p>}
      <button type="submit">送信</button>
    </form>
  )
}

app/actions.ts

"use server"

import { cookies } from "next/headers"
import { redirect } from "next/navigation"
import { z } from "zod"

const FORM_STATE_COOKIE = "example-form-state"

const schema = z.object({
  email: z.email({ error: "有効なメールアドレスを入力してください" }),
})

interface FormState {
  values: { email?: string }
  errors?: Record<string, string[]>
  success?: boolean
}

export async function getFormState(): Promise<FormState> {
  const cookieStore = await cookies()
  const data = cookieStore.get(FORM_STATE_COOKIE)?.value

  if (!data) return { values: {} }

  try {
    return JSON.parse(data) as FormState
  } catch {
    return { values: {} }
  }
}

export async function submitServerAction(formData: FormData): Promise<void> {
  const email = String(formData.get("email") ?? "")
  const cookieStore = await cookies()

  const result = schema.safeParse({ email })

  if (!result.success) {
    const errors = result.error.issues.reduce<Record<string, string[]>>(
      (acc, issue) => {
        const key = String(issue.path[0] ?? "form")
        acc[key] = [...(acc[key] ?? []), issue.message]
        return acc
      },
      {}
    )
    cookieStore.set(
      FORM_STATE_COOKIE,
      JSON.stringify({ values: { email }, errors }),
      { maxAge: 60, httpOnly: true }
    )
    redirect("/")
  }

  cookieStore.set(
    FORM_STATE_COOKIE,
    JSON.stringify({ values: {}, success: true }),
    { maxAge: 60, httpOnly: true }
  )

  console.log(result.data.email)
  redirect("/top")
}

解説3

上記例は、cookieを使うことでエラーメッセージの表示と入力値の保存を実現しています。
エラーの場合は入力フォームページにリダイレクトし、cookieから入力値を設定して項目ごとにエラーメッセージを表示します。
成功の場合は任意のページ(今回は/top)にリダイレクトします。

まとめ

また1つClient Componentsを駆逐できました。
リアルタイムにバリデーションエラーを表示したいのであればClient Componentsでの実装になりますが、そこまで重きをおく機能でなければ実装例3で事足りるのではないかと思います。
Server Componentsで実装することで、状態管理をする必要がなくなり、バリデーションのロジックもServer Actionsだけ見ればよくなったので、可読性やメンテナビリティの向上を実感しています。
もっとより良い実装例がありましたらコメントで教えていただけばと思います。

駆逐してやる、この世から1つ残らず ⚔️

補足

入力フォームで確認モーダル等を表示する場合は、formをClient Components(useActionState利用)にしchildrenでinputなどフォーム要素を受け取るようにすれば、最小限のClient Componentsにすることも可能です。

株式会社モニクル

Discussion