Next.js 14 で Server Actions が安定版となり正式に使えるようになりました!🎉
Server ActionsはNext 13までの書き方に比べてクライアント側はスッキリ書けるものの、
書き方や構成が結構変わったな、と感じる部分も多くあります。
そこで、この記事では
- メールアドレス・パスワードを入力
- Server Actionsでログインの成功・失敗を判断
- 成功の場合は別ページへ、失敗の場合はエラーメッセージを表示
という処理を順を追って実装していきます。
検証した環境
1 | next | 14.1.3 |
2 | react | 18.2.0 |
3 | zod | 3.23.8 |
Server Actionsの書き方
Server Actionsを作成する方法はシンプルです
'use server'
ディレクティブを付与- 非同期関数(
async
を用いる)
'use server'
export async function action() {
// 処理
}
また必ずサーバーで実行される関数なので
例えば useState
のようなクライアント側でしか使えない処理は使えません
'use server'
import { useState } from 'react'
export async function action() {
// エラーが起きる
// You're importing a component that needs useState.
// It only works in a Client Component but none of its parents are marked with "use client",
// so they're Server Components by default.
const [state, setState] = useState('')
}
ここからは分かりやすいform周りを参考に
- メールアドレス・パスワードを入力
- Server Actionsでログインの成功・失敗を判断
- 成功の場合は別ページへ、失敗の場合はエラーメッセージを表示
という内容をServer Actionsを用いて実装してみます
Server Actionsの雛形用意
まずログイン処理を記載する前、までを書いていきます
実際のプロダクトの場合
- DBと接続してログインの成功・失敗を判断する
- バックエンドのログイン用APIを呼び出す
といった処理を行うケースが多いと思いますが、ここでは
- メールアドレスが「test@test.com」
- パスワードが「password」
であれば成功とします
Server Actions用の関数の雛形を作成
'use server'
export async function login(formData: FormData) {
const email = formData.get('email')
const password = formData.get('password')
console.log('email:', email)
console.log('password:', password)
if (email === 'test@test.com' && password === 'password') {
// ログイン成功
} else {
// ログイン失敗
}
}
Server Actionsを呼び出すtsx側は以下のように、formタグの action
で呼び出すだけです
import { login } from '@/actions/login'
export default function LoginPage() {
return (
<div>
<h1>Login</h1>
<form action={login}>
<input type="text" placeholder="email" name="email" />
<input type="text" placeholder="password" name="password" />
<button type="submit">ログイン</button>
</form>
</div>
)
}
Server Actionsの関数が呼び出せています!
成功・失敗の対応
ログインの処理をapiに問い合わせしたような形に変更していきます
参考用のログイン処理を別関数に切り出します
本来はログイン成功した場合にaccessToken等を返すはずですがここでは割愛します
const sleep = (msec: number) => new Promise((resolve) => setTimeout(resolve, msec))
export async function execLogin(email: string, password: string) {
console.log('email:', email)
console.log('password:', password)
console.log('api接続中...')
// apiの実行を再現するため2秒待つ
await sleep(2000)
if (email === 'test@test.com' && password === 'password') {
// ログイン成功
console.log('success!')
return
} else {
// ログイン失敗
console.log('failed')
throw new Error('failed')
}
}
Server Actionsで↑の関数を呼び出します
'use server'
import { redirect } from 'next/navigation'
import { execLogin } from './execLogin'
export async function login(formData: FormData) {
const email = formData.get('email')
const password = formData.get('password')
// formData.getで取得した値は FormDataEntryValue という型になるため型チェック
if (typeof email !== 'string' || typeof password !== 'string') {
return { message: 'メールアドレスとパスワードを入力してください' }
}
try {
await execLogin(email, password)
} catch {
return { message: 'ログインに失敗しました' }
}
redirect('/dashboard')
}
ログインに成功した場合に dashboard にリダイレクトさせるように出来ました!
失敗した場合にUI上何も分からないため、次にServer Actionsのエラーを表示していきます
エラーの表示
エラーの表示をするためには useFormState
という react-dom
に用意されている関数を用います。
React 19で useActionState
という更に便利に使える関数が登場しました!
次に出てくる useFormStatus
と useFormState
を組み合わせたような関数のため
React 19が使える環境であれば積極的に使うのがおすすめです
useFormState
はその名の通り、formの状態を管理するための関数です。
formのアクションが呼ばれると指定した関数を実行し、Server Actionsの結果(戻り値)に応じた新しい状態を作成します
useFormState の参考として以下のようなものを用意してみました。
受け取った前の値をインクリメントするServer Actions
'use server'
// Server Actionsでの第一引数、戻り値として使用する型
type State = {
count: number
}
// previousState・・1回前の値が入る
// formData・・formで入力した値
export async function increment(previousState: State, formData: FormData): Promise<State> {
return { count: previousState.count + 1 }
}
useFormState を使ってインクリメントした値を表示
'use client'
import { useFormState } from 'react-dom'
import { increment } from '@/actions/increment'
export default function StatefulForm() {
// state・・formの状態
// formAction・・formのactionに指定する関数
// useFormState 第一引数・・formのactionとして実際に実行される処理
// useFormState 第二引数・・formのstateの初期値。Server ActionsのStateの型に準拠した値をセットする
const [state, formAction] = useFormState(increment, { count: 0 })
return (
<form action={formAction}>
<div>{state.count}</div>
<button>Increment</button>
</form>
)
}
この流れを先程のログイン用のServer Actionsに実装してみます
'use server'
import { redirect } from 'next/navigation'
import { execLogin } from './execLogin'
type State = {
message: string | null
}
export async function login(previousState: State, formData: FormData): Promise<State> {
const email = formData.get('email')
const password = formData.get('password')
if (typeof email !== 'string' || typeof password !== 'string') {
return { message: 'メールアドレスとパスワードを入力してください' }
}
try {
await execLogin(email, password)
} catch {
return { message: 'ログインに失敗しました' }
}
redirect('/dashboard')
}
'use client'
import { login } from '@/actions/login'
import { useFormState } from 'react-dom'
export default function LoginPage() {
const [state, formAction] = useFormState(login, { message: null })
return (
<div>
<h1>Login</h1>
<form action={formAction}>
<input type="text" placeholder="email" name="email" />
<input type="text" placeholder="password" name="password" />
<button type="submit">ログイン</button>
</form>
<div>{state.message}</div>
</div>
)
}
エラーメッセージが表示出来るようになりました!
処理実行中を表示する
サーバーに問い合わせをしている最中は
- ボタンをdisabledにする
- ローディングアイコンを表示する
- 画面全体にbackdropを表示する
等、UI上に何かしらFBを返すことが多いと思います。
formの実行状態を取得したい場合に useFormStatus
関数が有用です!
useFormStatus
はformの状態を取得する事が出来る関数で、例えば
pending
・・formが送信中であるかどうかdata
・・formに送信中の FormData の値
などを取得することが出来ます。
useFormStatusの使い方はReactの日本語公式ページに詳しく書かれています
Server Actionsは変更せず、ログイン周りを編集します
'use client'
import { login } from '@/actions/login'
import { useFormState, useFormStatus } from 'react-dom'
// useFormStatus を用いる場合formと別のコンポーネントにする必要がある
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" style={pending ? { opacity: '30%' } : {}} disabled={pending}>
{pending ? 'ログイン中...' : 'ログイン'}
</button>
)
}
export default function LoginPage() {
const [state, formAction] = useFormState(login, { message: null })
return (
<div>
<h1>Login</h1>
<form action={formAction}>
<input type="text" placeholder="email" name="email" />
<input type="text" placeholder="password" name="password" />
<SubmitButton />
</form>
<div>{state.message}</div>
</div>
)
}
ログイン中はボタンをdisabled表示出来るようになりました!
重要なポイントとして useFormStatus
は同じコンポーネントでレンダリングされた form のステータスは取得できません。
以下のように書くと、常にpendingはtrueが返ってきます
function Form() {
// 🚩 `pending` will never be true
// useFormStatus does not track the form rendered in this component
const { pending } = useFormStatus();
return <form action={submit}></form>;
}
回避するために別コンポーネント化する必要があります
function Submit() {
// ✅ `pending` will be derived from the form that wraps the Submit component
const { pending } = useFormStatus();
return <button disabled={pending}>...</button>;
}
function Form() {
// This is the <form> `useFormStatus` tracks
return (
<form action={submit}>
<Submit />
</form>
);
}
バリデーション を設定する
最後にバリデーション!
バリデーションにはzod
を使います
$ npm install zod
バリデーションの設定は少々複雑なため3段階に分けて記載します
- スキーマの定義
- バリデーションエラーを返す
- UIにエラーを表示
1.スキーマの定義
zodを使ってスキーマを定義し safeParse
関数を用いてバリデーションチェックを行います
UIで確認しやすいようバリデーションはかなり適当な定義です😂w
'use server'
import { z } from 'zod'
・・・
// バリデーション用のスキーマを定義
const schema = z.object({
email: z.string().email('メールアドレスの形式で入力してください'),
password: z
.string()
.min(4, '4文字以上で入力してください')
.max(12, '12文字以内で入力してください'),
})
type State = {
message: string | null
}
export async function login(previousState: State, formData: FormData): Promise<State> {
const email = formData.get('email')
const password = formData.get('password')
if (typeof email !== 'string' || typeof password !== 'string') {
return { message: 'メールアドレスとパスワードを入力してください' }
}
const validatedFields = schema.safeParse({ email, password })
if (!validatedFields.success) {
// バリデーションエラーが起きた場合の処理
}
・・・
}
2. バリデーションエラーを返す
Server Actions・コンポーネント上で使用する State
の型は自由に定義 出来ます。
今回はバリデーションでのエラーとそれ以外のエラーメッセージ、というイメージで定義します
type State = {
validationErrors?: {
email?: string[]
password?: string[]
}
serverError?: string
} | null
Stateを更新し、バリデーションでエラーが起きた場合にエラーを返すようにします
'use server'
import { z } from 'zod'
import { redirect } from 'next/navigation'
import { execLogin } from './execLogin'
const schema = z.object({
email: z.string().email('メールアドレスの形式で入力してください'),
password: z
.string()
.min(4, '4文字以上で入力してください')
.max(12, '12文字以内で入力してください'),
})
type State = {
validationErrors?: {
email?: string[]
password?: string[]
}
serverError?: string
} | null
export async function login(previousState: State, formData: FormData): Promise<State> {
const email = formData.get('email')
const password = formData.get('password')
if (typeof email !== 'string' || typeof password !== 'string') {
return { serverError: 'メールアドレスとパスワードを入力してください' }
}
const validatedFields = schema.safeParse({ email, password })
if (!validatedFields.success) {
return { validationErrors: validatedFields.error.flatten().fieldErrors }
}
try {
await execLogin(email, password)
} catch {
return { serverError: 'ログインに失敗しました' }
}
redirect('/dashboard')
}
3. UIにエラーを表示
上記でバリデーションエラーを返せるようになったのでUI上で表示します!
'use client'
import { login } from '@/actions/login'
import { useFormState, useFormStatus } from 'react-dom'
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" style={pending ? { opacity: '30%' } : {}} disabled={pending}>
{pending ? 'ログイン中...' : 'ログイン'}
</button>
)
}
export default function LoginPage() {
const [state, formAction] = useFormState(login, null)
return (
<div>
<h1>Login</h1>
<form action={formAction}>
<input type="text" placeholder="email" name="email" />
<input type="text" placeholder="password" name="password" />
<SubmitButton />
</form>
<div>{state?.validationErrors?.email?.join(',')}</div>
<div>{state?.validationErrors?.password?.join(',')}</div>
<div>{state?.serverError}</div>
</div>
)
}
バリデーションエラー/サーバーでのエラー、それぞれのメッセージが表示出来るようになりました!🎉
cssでの装飾を全くしていないというのもありますが、
クライエント側のコードはかなりスッキリしますね ✨