【Next.js】Server Actionsでログイン処理を順を追って書いてみる

投稿日
/
更新日
【Next.js】Server Actionsでログイン処理を順を追って書いてみる

Next.js 14 で Server Actions が安定版となり正式に使えるようになりました!🎉

Next.js 14 | Next.js




Server ActionsはNext 13までの書き方に比べてクライアント側はスッキリ書けるものの、

書き方や構成が結構変わったな、と感じる部分も多くあります。



そこで、この記事では

  1. メールアドレス・パスワードを入力
  2. Server Actionsでログインの成功・失敗を判断
  3. 成功の場合は別ページへ、失敗の場合はエラーメッセージを表示

という処理を順を追って実装していきます。

検証した環境

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周りを参考に

  1. メールアドレス・パスワードを入力
  2. Server Actionsでログインの成功・失敗を判断
  3. 成功の場合は別ページへ、失敗の場合はエラーメッセージを表示

という内容をServer Actionsを用いて実装してみます




Server Actionsの雛形用意

まずログイン処理を記載する前、までを書いていきます




実際のプロダクトの場合

  • DBと接続してログインの成功・失敗を判断する
  • バックエンドのログイン用APIを呼び出す

といった処理を行うケースが多いと思いますが、ここでは

  • メールアドレスが「test@test.com
  • パスワードが「password」

であれば成功とします




Server Actions用の関数の雛形を作成

src/actions/login.ts
'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 で呼び出すだけです

src/app/login/page.tsx
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等を返すはずですがここでは割愛します


src/actions/execLogin.ts
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で↑の関数を呼び出します

src/actions/login.ts
'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 という更に便利に使える関数が登場しました!


次に出てくる useFormStatususeFormState を組み合わせたような関数のため

React 19が使える環境であれば積極的に使うのがおすすめです




useFormState はその名の通り、formの状態を管理するための関数です。

formのアクションが呼ばれると指定した関数を実行し、Server Actionsの結果(戻り値)に応じた新しい状態を作成します




useFormState の参考として以下のようなものを用意してみました。


受け取った前の値をインクリメントするServer Actions

actions/increment.ts
'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 を使ってインクリメントした値を表示

app/form/page.tsx
'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に実装してみます

src/actions/login.ts
'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')
}
src/app/login/page.tsx
'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の日本語公式ページに詳しく書かれています

useFormStatus – React



Server Actionsは変更せず、ログイン周りを編集します

src/app/login/page.tsx
'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>
  );
}

useFormStatus – React より引用



バリデーション を設定する

最後にバリデーション!

バリデーションにはzodを使います

$ npm install zod


バリデーションの設定は少々複雑なため3段階に分けて記載します

  1. スキーマの定義
  2. バリデーションエラーを返す
  3. UIにエラーを表示

1.スキーマの定義

zodを使ってスキーマを定義し safeParse 関数を用いてバリデーションチェックを行います


UIで確認しやすいようバリデーションはかなり適当な定義です😂w

src/actions/login.ts
'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を更新し、バリデーションでエラーが起きた場合にエラーを返すようにします

src/actions/login.ts
'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上で表示します!

src/app/login/page.tsx
'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での装飾を全くしていないというのもありますが、

クライエント側のコードはかなりスッキリしますね ✨

プロフィール画像
Yuki Takara
都内でフリーランスのエンジニアをやってます。フロントとアプリ開発メインに幅広くやってます。