見出し画像

Next.jsでServer Actionsを使ってみる



こんにちは。
先日、Next.js 13にデビューしました。
Next.js 13.4の変更点について書かれた記事は、以下をご参照ください。

Next.js 13.4の変更点では、まだアルファ版ですが「Server Actions」という機能が追加されています。
Server Actionsを活用する中で、詰まった箇所がありましたので、その対応も含めてノートに残します。

1.Server Actionsとは?

Next.jsのServer Actionsにより、クライアントサイドでのフォーム送信イベントをトリガーとして、サーバーサイドでの関数実行が可能となりました。
従来のアプローチでは、フォームの処理に関してはAPI Routesを使用してAPIを立ち上げ、POSTメソッドからのリクエストボディを取得し、それに基づいた処理を実施していました。
しかしながら、Server Actionsを利用すれば、APIの実装を行わずに、フォームの処理をサーバーサイドだけで完結させることが可能です。

2.Server Actionsをはじめる

next.config.jsに serverActions: true という設定を追加することで、Server Actionsの機能を利用することができます。
ただし、従来の pages ディレクトリではなく、App Routerを使用しないとServer Actionsは活用できませんので、こちらの点には特に注意が必要です。

// next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
    experimental: {
    serverActions: true,
  },
}

module.exports = nextConfig

3.Server ActionsでForm処理をする

従来の手法では、まずAPI Routeを使ってAPIを定義していました。
そして、フォームのsubmitイベントが発生した際に、そのbodyをAPIのエンドポイントにPOSTし、処理を行い、その結果としてのレスポンスを取得してステートを更新するといった流れが一般的でした。

それでは、新たな機能として導入されたServer Actionsを用いる場合、具体的にはどのような手順を踏むのでしょうか?

まずはコンポーネントをつくっていきます。

3-1.<Form>コンポーネントの作成

// InputText.tsx

import React from 'react'
import { Wrapper } from '@/components/elements/Input/InputText/InputText.style'

//---------------------------------------------
// props
//---------------------------------------------
export type Props = {
  name: string
  value?: string
  placeholder?: string
  error?: boolean
  disabled?: boolean
  min?: number
  max?: number
  onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
}

//---------------------------------------------
// component
//---------------------------------------------
export const InputText = ({ name, value, placeholder, error, disabled, min, max, onChange }: Props) => {
  return (
    <Wrapper
      type={'text'}
      name={name}
      value={value}
      placeholder={placeholder}
      error={error}
      disabled={disabled}
      minLength={min}
      maxLength={max}
      onChange={onChange}
    />
  )
}

さて、必要なコンポーネントの準備が整ったところで、次に<Form>コンポーネントの構築に取り掛かります。
一般的に、フォームは以下のような構造で作成されることが多いですね。

// Form.tsx

'use client'

import React, { useState } from 'react'
import { InputText } from '@/components/elements/Input/InputText/InputText'
import { addForm } from '@/actions/addForm'

//---------------------------------------------
// props
//---------------------------------------------
type Props = {
  datas: {
    username: string;
  }
}

//---------------------------------------------
// component
//---------------------------------------------
export const Form = ({ datas }: Props) => {

  const [formData, setFormData] = useState({
    username: datas.username
  });

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target

    setFormData((prev) => {
      return { ...prev, [name]: value };
    });
  }

  return (
    <>
      <div>
        <p>username: {formData.username}</p>
      </div>
      <form action={addForm}>
        <InputText
          name={'username'}
          value={formData.username}
          placeholder={'文章を入力する'}
          onChange={handleChange}
        />
        <button>情報を送信する</button>
      </form>
    </>
  )
}

ページコンポーネントから<Form>コンポーネントを読み込みます。

// Page/tsx

import { Form } from '@/components/Form'

export default function Home() {
  return (
    <Form
      datas={{
        username: 'ヤマダタロウ'
      }}
    />
  )
}

3-2.Server Actionsの作成

ではいよいよ、Server Actionsをつくっていきたいと思います。
まずはじめに、usernameだけをPOSTして保存するServer Actionsです。

// addForm.ts

import { sql } from "@vercel/postgres";
 
async function addForm(formData: FormData) {
  'use server';
 
  const username = formData.get('username');
 
  // バリデーションが入る場合はバリデーションを入れる

  await sql`INSERT INTO users (username) VALUES (username)`;
}

3-3.'use server'ディレクティブ

async function addForm(formData: FormData) { 
  'use server';

  // ...
}

Server Actions関数を使用する際は、関数のトップレベルに use server ディレクティブを宣言することが必須です。

use serverディレクティブの宣言を忘れ、その関数を<Form />のaction属性に指定すると、その関数はクライアントサイドでの実行を試みることとなり、結果としてエラーが生じます。

// addForm.ts

'use server';
 
import { sql } from "@vercel/postgres";
 
async function addForm(formData: FormData) {
  // ...
}

ファイルのトップレベルにuse serverディレクティブを宣言することは可能ですが、その場合、同一ファイル内にクライアントコンポーネントを配置することができなくなります。
そのため、Server Actionsは専用のファイルに分けて管理するのがおすすめです。

3-4.Server Actions

// addForm.ts

'use server';

async function addForm(formData: FormData) { 
  const username = formData.get('username');
 
  // バリデーションが入る場合はバリデーションを入れる

  await sql`INSERT INTO users (username) VALUES (username)`;
}

// Form.tsx
return (
    <form action={addForm}>
      <InputText
        name={'username'}
        value={formData.username}
        placeholder={'文章を入力する'}
        onChange={handleChange}
      />
// ...

<form action={addForm} のように、Server Actionsがformのaction属性に設定されています。
こちらの形式を用いると、formData.get('username') のように、フォームに入力されたデータを受け取ることができます。

このServer Actions内にはuse serverディレクティブが記述されているため、処理はサーバー側で行われます。
サーバー上での処理という性質上、セッションからのデータ取得、データベースへの接続、環境変数へのアクセスなど、さまざまな操作が可能となります。

これまではAPI Routesを用いてAPIを作成し、POST処理を行っていた手順が、Server Actionsによってよりシンプルに実現できるようになりました。

3-5.データの更新

addFormに入力されたデータを取得し、そのデータを基にデータベースを更新する動作を検証します。
以下の方法で、sql関数を活用してデータベースからユーザーの情報を取得し、それを表示します。

Next.jsのバージョン13からは、デフォルトでServer Componentsが採用されているため、Server Componentsから直接データベースへアクセスすることが可能です。

// Page/tsx

import { Form } from '@/components/Form'

export default function Home() {
  const { rows } = await sql`SELECT * FROM users ORDER BY created_at DESC`;

  return <Form datas={rows} />
}

3-6.データが更新されない

フォームの内容は正しく取得できていますが、1つ問題があります。
フォーム送信後、内容が直ちに表示されていません。

PHPなどの古典的なフォーム送信を思い返してみると、フォーム送信後、ページが更新される動作が一般的でした。
この点において、Next.jsも同様の動作となります。

Server Actionsを用いてデータを更新した際には、redirectを実行するか、revalidatePathrevalidateTagのいずれかを呼び出してキャッシュを更新する手順が必要です。

3-7.色々なパターンのinputをServer Actionsを利用する

usernameは単一の文字列をPOSTしていましたが、radiobbox / checkboxのようなフォームがあることは珍しくはありません。

それでは早速試していきます。
<InputCheck>コンポーネントと、<InputRadio>コンポーネントを追加します。

// InputCheck.tsx

import React from 'react'
import { Label, Input, Checked, Inner } from '@/components/elements/Input/InputCheck/InputCheck.style'

//---------------------------------------------
// props
//---------------------------------------------
export type Props = {
  name: string
  id?: string
  value: string
  checked: boolean
  onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
}

//---------------------------------------------
// component
//---------------------------------------------
export const InputCheck = ({ name, id, value, checked, onChange }: Props) => {
  return (
    <Label checked={checked} htmlFor={id}>
      <Input type={'checkbox'} id={id} name={name} value={value} checked={checked} onChange={onChange} />
      {checked && <Checked checked={checked} />}
      <Inner>{value}</Inner>
    </Label>
  )
}
// InputRadio.tsx

import React from 'react'
import { Label, Input, Wrapper, Inner } from '@/components/elements/Input/InputRadio/InputRadio.style'

//---------------------------------------------
// props
//---------------------------------------------
export type Props = {
  name: string
  value: string
  checked: boolean;
  onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
}

//---------------------------------------------
// component
//---------------------------------------------
export const InputRadio = ({ name, value, checked, onChange }: Props) => {

  return (
    <>
      <Label>
        <Input
          type={'radio'}
          name={name}
          value={value}
          checked={checked}
          onChange={onChange}
        />
        <Wrapper checked={checked}>
          <Inner>{value}</Inner>
        </Wrapper>
      </Label>
    </>
  )
}
// Form.tsx

'use client'

import React, { useState } from 'react'
import { InputText } from '@/components/elements/Input/InputText/InputText'
import { InputRadio } from '@/components/elements/Input/InputRadio/InputRadio'
import { InputCheck } from '@/components/elements/Input/InputCheck/InputCheck'
import { addForm } from '@/actions/addForm'

//---------------------------------------------
// props
//---------------------------------------------
type Props = {
  datas: {
    username: string;
    genderList: {
      name: string;
      checked: boolean;
    }[]
    genreList: {
      name: string;
      checked: boolean;
    }[]
  }
}

//---------------------------------------------
// component
//---------------------------------------------
export const Form = ({ datas }: Props) => {

  const [formData, setFormData] = useState({
    username: datas.username,
    gender: datas.genderList,
    genre: datas.genreList
  });

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value, checked } = e.target

    setFormData((prev) => {
      if (name === 'genre') {
        const updatedGenre = prev.genre.map((genre) =>
          genre.name === value ? { ...genre, checked } : genre
        );
        return { ...prev, genre: updatedGenre };
      }

      if(name === 'gender') {
        const updatedGender = prev.gender.map((gender) =>
          gender.name === value ? { ...gender, checked } : { ...gender, checked: false }
        );
        return { ...prev, gender: updatedGender };
      }
      return { ...prev, [name]: value };
    });
  }

  return (
    <>
      <form action={addForm}>
        <InputText
          name={'username'}
          value={formData.username}
          placeholder={'文章を入力する'}
          onChange={handleChange}
        />
        {formData.genre.map((item, index) => {
          return (
            <InputCheck
              name={`genre`}
              id={`${item.name}_${index}`}
              value={`${item.name}`}
              checked={formDatas.genre[index].checked}
              onChange={handleChange}
            />
          )}
        )}
        {formData.gender.map((item, index) => {
          return (
            <InputRadio
              name={'gender'}
              value={`${item.name}`}
              checked={formDatas.gender[index].checked}
              onChange={handleChange}
            />
          )}
        )}
        <button>情報を送信する</button>
      </form>
    </>
  )
}
// Page/tsx

import { Form } from '@/components/Form'

export default function Home() {
  return (
    <Form
      datas={{
        username: 'ヤマダタロウ',
        gender: [
          { name: 'male', checked: true },
          { name: 'female', checked: false },
        ],
        genre: [
          { name: 'Rock', checked: false },
          { name: 'Pop', checked: true },
          { name: 'Soul', checked: false },
          { name: 'Classic', checked: false },
        ],
      }}
    />
  )
}

コンポーネントの準備ができました。

ここでひとつ注意点があります。
usernameは単一の文字列をPOSTしていましたが、genderやgenreは以下のような配列でPOSTしたいとします。

gender: [
  { name: 'male', checked: true },
  { name: 'female', checked: false },
],
genre: [
  { name: 'Rock', checked: false },
  { name: 'Pop', checked: true },
  { name: 'Soul', checked: false },
  { name: 'Classic', checked: false },
]

以下のように、formActionにそのままServer Actions関数を渡すと配列で渡すことができません。

// Form.tsx

<form action={addForm}>
 // ...

ではどのようにすればよいでしょうか?

async / awaitで無名関数のコールバックとしてServers Actionsを呼び、引数にフォームのstateを入れて渡します。

// Form.tsx

<form
  action={async () => {
    await addForm(formDatas)
  }}

  // ...
// addForm.ts

'use server';

export async function addForm({ username, gender, genre }) {

  // 以下フォームから送信されてきたデータ
  // console.log({ username, gender, genre })
  // {
  //   username: 'ヤマダタロウ',
  //   gender: [
  //     { name: 'male', checked: true },
  //     { name: 'female', checked: false }
  //   ],
  //    genre: [
  //     { name: 'Rock', checked: false },
  //     { name: 'Pop', checked: true },
  //     { name: 'Soul', checked: false },
  //     { name: 'Classic', checked: false }
  //   ]
  // }

  return { username, gender, genre };
}

このようにformActionでそのままServer Actionsを呼ぶのではなく、コールバックで呼び出すことで配列で渡すことができます。

3-8.form以外でも使えるServer Actions

Server Actionsは、formAction以外の部分でも利用することができます。この実装にはuseTransitionstartTransitionを活用します。
ただし、useTransitionはClient Componentsでのみ使用可能なので、その点には注意が必要です。

それでは、データの送信ボタン部分を具体的に切り出してみましょう。

'use client';
 
import { useTransition } from "react";
import { addForm } from '@/actions/addForm'
 
//---------------------------------------------
// props
//---------------------------------------------
type Props = {
  datas: {
    username: string
  }
}
 
//---------------------------------------------
// component
//---------------------------------------------
const InputUser({ username, gender }: Props) {
  const [isPending, startTransition] = useTransition();

  return (
    <>
      <button onClick={() => startTransition(() => addForm(username))}>
        {isPending ? "loading..." : "情報を送信する"}
      </button>
    </>
  );
}

それでは、<Form>コンポーネントに、先ほど切り出したコンポーネントを組み込みましょう。

// Form.tsx

'use client'

import React, { useState } from 'react'
import { InputText } from '@/components/elements/Input/InputText/InputText'
import { InputRadio } from '@/components/elements/Input/InputRadio/InputRadio'
import { InputUser } from '@/components/elements/Input/InputUser//InputUser'

//---------------------------------------------
// props
//---------------------------------------------
type Props = {
  datas: {
    username: string
  }
}

//---------------------------------------------
// component
//---------------------------------------------
export const Form = ({ datas }: Props) => {

  const [formData, setFormData] = useState({
    username: datas.username,
    gender: datas.gender
  });

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value
    }));
  };

  return (
    <>
      <div>
        <p>username: {formData.username}</p>
      </div>
      <InputText
        name={'username'}
        value={formData.username}
        placeholder={'文章を入力する'}
        onChange={handleChange}
      />
      <InputUser datas={formData} />
    </>
  )
}

formActionを使わなくても、データの更新が正常に行えました。

3-9.revalidatePathで更新する

Server Actions内でrevalidatePathを実行するように修正してみましょう。
sqlを使用してデータを更新した直後に、revalidatePathを呼び出します。


// addForm.ts

'use server';
 
import { sql } from "@vercel/postgres";
import { revalidatePath } from "next/cache";
 
async function addForm(formData: FormData) {
  'use server';
 
  const username = formData.get('username');
 
  // バリデーションが入る場合はバリデーションを入れる

  await sql`INSERT INTO users (username) VALUES (username)`;
  
  // DBを更新したあとに呼び出す
  revalidatePath("/");
}

フォームで送信したデータが、画面に表示されました。

4.その他の小さな諸々

4-1.バリデーション

バリデーションは絶対に欠かせません。
バリデーションには、クライアント側で実施するものと、サーバー側で実施するものが存在します。
サーバー側のバリデーションは比較的シンプルで、Server Actions内でデータの正当性を確認するだけです。
それでは、クライアント側ではどのようにしてバリデーションを行うのでしょうか?

// Form.tsx

'use client'

import React, { useState } from 'react'
import { InputText } from '@/components/elements/Input/InputText/InputText'
import { InputRadio } from '@/components/elements/Input/InputRadio/InputRadio'
import { addForm } from '@/actions/addForm'

//---------------------------------------------
// props
//---------------------------------------------
type Props = {
  datas: {
    username: string;
  }
}

//---------------------------------------------
// component
//---------------------------------------------
export const Form = ({ datas }: Props) => {

  const formRef = useRef<HTMLFormElement>(null);
  const [formData, setFormData] = useState({
    username: datas.username
  });

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value
    }));
  };

  return (
    <>
      <div>
        <p>username: {formData.username}</p>
      </div>
      <form
        action={async (formData) => {
          setError(null);
          const username = formData.get('username');
          if (typeof username !== 'string' || username.length === 0) {
            setError('Username cannot be empty');
            return;
          }
          if (username.length > 20) {
            setError('Username cannot be longer than 20 characters');
            return;
          }
          await addForm(username);
          formRef.current?.reset();
        }}  
        ref={formRef}
      >
        <InputText
          name={'username'}
          value={formData.username}
          placeholder={'文章を入力する'}
          onChange={handleChange}
        />
        <button>情報を送信する</button>
      </form>
    </>
  )
}

actionの中では、async / awaitを使用してバリデーションを行った後に、Server ActionsにデータをPOSTするように設定します。
この変更により、Server ActionsではformDataを直接受け取らなくなるため、コードに少し修正を入れます。

// addForm.ts

'use server';
 
import { sql } from "@vercel/postgres";
import { revalidatePath } from "next/cache";
 
async function addForm(username: string) {
  'use server';
  
  // バリデーションが入る場合はバリデーションを入れる

  await sql`INSERT INTO users (username) VALUES (username)`;
  
  // DBを更新したあとに呼び出す
  revalidatePath("/");
}

4-2.エラーハンドリング

Server Actionsを使用した場合のエラーハンドリングです。

// addForm.ts

'use server';

export async function addForm({ username }) {
  throw new Error('エラーが発生しました');
}
// Form.tsx

<form
  action={async () => {
    try {
      await addForm(formDatas)
      alert(res.username)
    } catch (error) {
      setError('エラー発生')
      console.log(error) // throwされたErrorをキャッチできる
    }
  }}
>

Server Actionsで例外を発生させてみると、formActionのコールバックでキャッチすることができます。

4-3.レスポンス

Server Actionsを使用した場合のレスポンスです。

// addForm.ts

'use server';

export async function addForm({ username }) {
  console.log({ username })
  return { username: `${username}さん` } 
}
// Form.tsx

<form
  action={async () => {
    const res = await addForm(formDatas)
    console.log(res.username) // "ヤマダタロウさん"
  }}
>

Server ActionsでreturnすればそのままServer Actions関数の返り値として受け取ることができます。

4-4.クッキー

Server Actionsを使用した場合の状態管理です。

'use server';

import { revalidateTag } from 'next/cache';
import { cookies } from 'next/headers';

export async function addForm({ username, gender, amenities }) {

  cookies().set('username', username);
  cookies().set('gender', JSON.stringify(gender)); // 配列なのでJSON.stringifyで保存する
  cookies().set('genre', JSON.stringify(genre)); // 配列なのでJSON.stringifyで保存する

  revalidateTag('/');
}

Server Actionsではサーバー側にアクセスできるのでcookieに直接状態を保存することができます。

5.まとめ

● Server Actionsを利用することで、クライアントサイドのイベント、例えばフォームの送信やボタンのクリックから、サーバサイドの処理としてデータベースの更新などを行うことが可能。
● データ更新の後には、revalidatePathrevalidateTagを使用してキャッシュを更新する必要がある。

Next.jsのServer Actionsは非常に便利な機能です。
これまでgetServerSidePropsAPI Routesを使って実現していた処理を、APIの作成を省略して直接サーバー側で処理できるようになったのは大変画期的です。

これにより、一周回ってRailsのような感覚を得ることができます。
もちろん、フレームワークとしてReactを採用しているため、高度なフロントエンドの実装も簡単に行えます。

Server ComponentsやServer Actionsの導入により、従来のサーバーサイドへの移行が加速し、シンプルなWebアプリケーションであれば、Next.jsだけでの構築が現実的になったのではないでしょうか。

それでは。

この記事が気に入ったらサポートをしてみませんか?