ロゴテキスト ロゴ

    【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 に用意されているカスタムフックを用います。



    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>
      )
    }


    重要なポイントとして 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
    都内でフリーランスのエンジニアをやってます。フロントとアプリ開発メインに幅広くやってます。