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を実行するか、revalidatePathやrevalidateTagのいずれかを呼び出してキャッシュを更新する手順が必要です。
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以外の部分でも利用することができます。この実装にはuseTransitionのstartTransitionを活用します。
ただし、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.まとめ
Next.jsのServer Actionsは非常に便利な機能です。
これまでgetServerSidePropsやAPI Routesを使って実現していた処理を、APIの作成を省略して直接サーバー側で処理できるようになったのは大変画期的です。
これにより、一周回ってRailsのような感覚を得ることができます。
もちろん、フレームワークとしてReactを採用しているため、高度なフロントエンドの実装も簡単に行えます。
Server ComponentsやServer Actionsの導入により、従来のサーバーサイドへの移行が加速し、シンプルなWebアプリケーションであれば、Next.jsだけでの構築が現実的になったのではないでしょうか。
それでは。
この記事が気に入ったらサポートをしてみませんか?