🎉

useActionStateとServer Actionsを利用したフォーム実装(zodでバリデーションも)

2024/06/21に公開

動機

  • ServerActionsでformの実装をするとuseFormStateで実装する記事がほとんどです。
  • pending状態を取得する場合は、 useFormStateuseFormStatus を併用しなければならないため、どうしても処理が煩雑になってしまう
  • それを解消できるreact19の新hooksのuseActionStateで実装したサンプルがあったら「みんなの役に少しでも立てるのかも!」と思って作りました。
  • 実装の過程を載せていた方が、実際の実装の流れをわかりやすく表現できると思ったため、少し長いですが過程も乗せています。

参考

  • useFormStateとuseFormStatusの問題点についてわかりやすくまとまっています。

https://zenn.dev/zksytmkn/articles/cf2acb2faf7cd2#useactionstate-の導入

前提

  • next version: 14.3.0-canary.47 (🚨まだ公式サポートされてないので注意)
  • react version: 18~
  • フロントエンドでのバリデーションは行わず、サーバーサイドでのバリデーションを行う
    • React-hook-formはサーバーサイドバリデーションに対応していないため
    • 詳しくはこの記事が参考になった

useActionStateの使い方

公式Doc
https://ja.react.dev/reference/react/useActionState

構文

const [state, formAction, pending] = useActionState(fn, initialState, permalink?);
  • fn:

    • フォームが送信されたりボタンが押されたりしたときに呼び出される関数 (コールバック関数)

    • fnには基本的にserver-actionsが登録される

    • fnの1 番目の引数には

      • 初回は渡した initialState を受け取る
      • 2 回目以降は前回の返り値を受け取る
    • 第二引数としてはフォームアクションが通常受け取る引数を受け取ります。

    • 今回の実装だと👇

      
      export async function registerUser(
        prevState: RegisterState,
        formData: FormData
      ): Promise<RegisterState> {
      ...
      }
      
  • initialState

    • state の初期値
    • アクションが一度呼び出された後は無視される
  • permalink

    • ダイナミックなコンテンツ(ページフィードなど)のあるページでプログレッシブエンハンスメントを組み合わせる場合に使用します。
    • fn がサーバアクションであり、かつフォームが JavaScript バンドルの読み込み完了前に送信された場合、ブラウザは現在のページ URL ではなくこの指定されたパーマリンク用 URL に移動するようになります。
    • あまり使い道がないと思っているので割愛

まずは、挙動を確認します!

Form(presentation)側の実装

  • tailwindを利用しているため少しごちゃついて見えるのは勘弁🙏

  • カスタムコンポーネントについて

    • 実装がわかりやすくなるようにするため、カスタムコンポーネントにスタイルを隠蔽しています
    • 詳しくはgithubにリポジトリを作ったのでをご参照ください
    • FormElementはlabelタグとinputタグが入っているコンポーネントです
    • SubmitButtonはloadingのUIを兼ね備えたボタンです
  • 実際に useActionState を使う場合👇のような構文になります

    stateとformActionとpendingを useActionState から取得することができます

      const [state, formAction, pending] = useActionState(
        registerUser,
        INITIAL_STATE
      );
    
  • formタグのaction属性に useActionState から取得した formAction を入れます

  • formActionが実行されると、registerUserが呼び出されます

  • registerUserにはformで入力して送信したデータと、それ以前のデータが格納されます。(server actions側の処理で詳しく解説します。)

  • useActionStateを使うことで、formの入力データに加えて、pendingじょうたいも取得できるようになりました

コード全体

/src/app/page.tsx

'use client';

import { useActionState } from 'react';
import { SubmitButton } from '@/components/custom/SubmitButton';
import { FormElement } from '@/components/custom/FormElement';
import {
  ProfileFormProps,
  registerUser,
} from './data/actions/registerUser-actions';

const INITIAL_STATE: ProfileFormProps = {
  name: 'hoge',
  email: 'hoge@email.com',
};

export default function Home() {
  const [state, formAction, pending] = useActionState(
    registerUser,
    INITIAL_STATE
  );
  return (
    <div className="max-w-screen-md mx-auto mt-10 grid gap-10 font-bold text-large text-center">
      <h1>Form Sample With 'useActionState'</h1>
      
      <form className="grid gap-8" action={formAction}>
        <FormElement item="name" data={state.name} />
        <FormElement item="email" data={state.email} />
        <div>
          <SubmitButton
            text="プロフィールを登録する"
            loadingText="登録中..."
            className="bg-blue-500 text-white rounded-md mt-8"
            pending={pending}
          />
        </div>
      </form>
    </div>
  );
}

useActionState を使うことでローディングが実行できました!

次にロジックの実装を行なっていきます。

useActionState のfnへ渡す第一引数と第二引数がどうなっているのか検証

useActionState のコールバック関数(構文のところの fn )に何が渡ってくるか検証すると

  • prevStateには送信時に入力されているformのデータ

  • formDataには実際にformに入力したデータのSymbol

  • formDataを加工することで、入力されたデータを取得することができる

     const rawFormData = Object.fromEntries(formData);
     // payloadが受け取ったデータ
      const payload = {
        name: rawFormData.name,
        email: rawFormData.email,
      };
    

が入ってきます

/src/app/data/actions/registerUser-actions.ts

'use server';

// sleepで通信を再現している
const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));

export interface ProfileFormProps {
  name: string;
  email: string;
}

export async function registerUser(
  prevState: ProfileFormProps,
  formData: FormData
) {
  const rawFormData = Object.fromEntries(formData);

  // payloadが受け取ったデータ
  const payload = {
    name: rawFormData.name,
    email: rawFormData.email,
  };

  console.log(prevState);
  console.log(formData);
  console.log(payload);

  // 本来であればここでAPIを叩いてユーザー登録処理を行う
  await sleep(2000);
  
  return prevState;
}

コンソールを確認して検証してみます

を入力します

before

after

そして登録ボタンを押します

console(sereverのコンソール)にはこのようにデータが入ってきます

prevState👇
  { name: 'hoge', email: 'hoge@email.com' }

formData👇
 {
    Symbol(state): Array(6) [
      { name: '$ACTION_REF_1', value: '' },
      {
        name: '$ACTION_1:0',
        value: '{"id":"41a7a5a2d9f0fc107a2730e3d2bff88f2d88838a","bound":"$@1"}'
      },
      {
        name: '$ACTION_1:1',
        value: '[{"name":"hoge","email":"hoge@email.com"}]'
      },
      { name: '$ACTION_KEY', value: 'k22119088' },
      { name: 'name', value: 'hoge-new' },
      { name: 'email', value: 'hoge@email.com' }
    ]
  }
  
 payload👇
  { name: 'hoge-new', email: 'hoge-new@email.com' }

何が入ってくるか確認ができました。

次に、これらのデータを元に、バリデーションやデータの永続化を行なっていきます。

server actionsのロジックを実装

server actionsでの処理の流れ(これから実装していきます)

  1. バリデーション
    1. 受け取ったデータ(payload)を元にバリデーションを行う
    2. バリデーションはzodで行う
    3. バリデーションエラーがあったらerror statusとpayloadを返す(入力済みの状態に戻す)
      • どこのformでバリデーションエラーがあるのか判定して、 Input の下にエラーメッセージを出す
      • バリデーションエラーがあった場合には、payloadを返してユーザーが入力したデータを修正してもう
      • バリデーションエラーがあった際は、実際のデータベースに登録したり、APIのリクエストを投げる必要がないため、早期にリターンする
    4. バリデーションが通ったらデータの継続化フェーズに入る
  2. データの永続化の実装
    1. データベースにデータを登録したり、APIにフォームのデータを含めてリクエストを投げる
    2. データの登録に 失敗したらerror statusとpayloadを返す
      • errorステータスはバリデーションの物とは違うので、 Input の下ではなく SubmitButton の下にエラーメッセージを出す
    3. データの登録に成功したら、処理を完了するして 完了ページへ遷移する

バリデーションの実装

zodのスキーマを作成する

// zodのスキーマ定義
const schemaRegister = z.object({
  name: z
    .string()
    .min(3, {
      message: 'ユーザー名は3文字以上で入力してください',
    })
    .max(20, {
      message: 'ユーザー名は3文字以上20文字以下で入力してください',
    }),
  email: z.string().email({
    message: '有効なEメールアドレスを入力してください',
  }),
});

zodのバリデーションエラーの定義

/src/app/data/actions/registerUser-actions.ts

'use server';

import { redirect } from 'next/navigation';
import { z } from 'zod';

const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));

// ダミーのAPI
const registerDB = async (data: ProfileFormProps) => {
  try {
    await sleep(2000);
    return { data: data, error: null };
  } catch (error) {
    console.error('Registration Service Error:', error);
    return { data: null, error: error as RegisterErrors };
  }
};

export interface ProfileFormProps {
  name: string;
  email: string;
}
export type RegisterErrors = string[] | null;
export type ZodErrors = {
  name?: string[];
  email?: string[];
} | null;
export type Message = string | null;
export type RegisterState = ProfileFormProps & {
  zodErrors: ZodErrors;
  registerErrors: RegisterErrors;
  message: Message;
};

// zodのスキーマ定義
const schemaRegister = z.object({
  name: z
    .string()
    .min(3, {
      message: 'ユーザー名は3文字以上で入力してください',
    })
    .max(20, {
      message: 'ユーザー名は3文字以上20文字以下で入力してください',
    }),
  email: z.string().email({
    message: '有効なEメールアドレスを入力してください',
  }),
});

export async function registerUser(
  prevState: RegisterState,
  formData: FormData
): Promise<RegisterState> {
  const validatedFields = schemaRegister.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
  });

  const rawFormData = Object.fromEntries(formData);

  // 型ガード
  const payload = {
    name: typeof rawFormData.name === 'string' ? rawFormData.name : '',
    email: typeof rawFormData.email === 'string' ? rawFormData.email : '',
  };

  // zodのバリデーション
  if (!validatedFields.success) {
    return {
      ...payload,
      zodErrors: validatedFields.error.flatten().fieldErrors,
      registerErrors: null,
      message: '入力エラーがあります。修正してください。',
    };
  }

// 一旦受け取った値を返す
retrun payload
}

zodのエラーは以下のように取得できる

console(server)

zodErrors: {
  name: [ 'ユーザー名は3文字以上で入力してください' ],
  email: [ '有効なEメールアドレスを入力してください' ]
}

zodのエラー文言を確認できました!

次はzodのエラーがあった際は、エラー文言を出したいため、page.tsxに変更を加えます。

  • page.tsxのINITIAL_STATEを更新
  • FormElementにerrorsを渡すようにした
  • FormElementではerrorが存在する場合、ZodErrorコンポーネントを呼んでエラーを表示する

/src/app/page.tsx

'use client';

import { useActionState } from 'react';
import { SubmitButton } from '@/components/custom/SubmitButton';
import { FormElement } from '@/components/custom/FormElement';
import {
  RegisterState,
  registerUser,
} from './data/actions/registerUser-actions';

const INITIAL_STATE: RegisterState = {
  name: 'hoge',
  email: 'hoge@email.com',
  zodErrors: null,
  registerErrors: null,
  message: null,
};

export default function Home() {
  const [state, formAction, pending] = useActionState(
    registerUser,
    INITIAL_STATE
  );
  return (
    <div className="max-w-screen-md mx-auto mt-10 grid gap-10 font-bold text-large text-center">
      <h1>Form Sample With 'useActionState'</h1>
      <form className="grid gap-8" action={formAction}>
        <FormElement
          item="name"
          data={state.name}
          errors={state.zodErrors && state.zodErrors.name}
        />
        <FormElement
          item="email"
          data={state.email}
          errors={state.zodErrors && state.zodErrors.email}
        />
        <div>
          <SubmitButton
            text="プロフィールを登録する"
            loadingText="登録中..."
            className="bg-blue-500 text-white rounded-md mt-8"
            pending={pending}
          />
        </div>
      </form>
    </div>
  );
}

データの永続化エラーの実装

以下のエラー処理を行う

  • レスポンスがない場合のエラー処理
  • データ登録エラーが発生した場合のエラー処理
  • エラーの種類によって戻り値のmessageを変更している
    • レスポンスエラー ⇒ “サーバーからのレスポンスがありません”
    • データ登録エラー ⇒ “データ登録エラーが発生しました”
  • 全てのエラーチェックを通ったら、完了画面に遷移

エラー文言を表示できるようにする

まずはエラー文言を表示できるようにする

  • <RegisterErrors registerErrors={state.message} /> を追加
  • バリデーションエラーと違ってサーバーエラーなので、 <Submit> ボタンの下に設置

/src/app/page.tsx

'use client';

import { useActionState } from 'react';
import { SubmitButton } from '@/components/custom/SubmitButton';
import { FormElement } from '@/components/custom/FormElement';
import {
  RegisterState,
  registerUser,
} from './data/actions/registerUser-actions';
import { RegisterErrors } from '@/components/custom/RegisterErrors';

const INITIAL_STATE: RegisterState = {
  name: 'hoge',
  email: 'hoge@email.com',
  zodErrors: null,
  registerErrors: null,
  message: null,
};

export default function Home() {
  const [state, formAction, pending] = useActionState(
    registerUser,
    INITIAL_STATE
  );
  return (
    <div className="max-w-screen-md mx-auto mt-10 grid gap-10 font-bold text-large text-center">
      <h1>Form Sample With 'useActionState'</h1>
      <form className="grid gap-8" action={formAction}>
        <FormElement
          item="name"
          data={state.name}
          errors={state.zodErrors && state.zodErrors.name}
        />
        <FormElement
          item="email"
          data={state.email}
          errors={state.zodErrors && state.zodErrors.email}
        />
        <div>
          <SubmitButton
            text="プロフィールを登録する"
            loadingText="登録中..."
            className="bg-blue-500 text-white rounded-md mt-8"
            pending={pending}
          />
        </div>
        <RegisterErrors registerErrors={state.message} />
      </form>
    </div>
  );
}

レスポンスがない場合のエラー処理

/src/app/data/actions/registerUser-actions.ts

'use server';

// ダミーのAPI
const registerDB = async (data: ProfileFormProps) => {
  try {
    await sleep(2000);

    return null; // レスポンスがないことを表現
  } catch (error) {
    return { data: null, error: error as RegisterErrors };
  }
};

// ...

export async function registerUser(
  prevState: RegisterState,
  formData: FormData
): Promise<RegisterState> {
  const validatedFields = schemaRegister.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
  });

// ...

  const responseData = await registerDB(validatedFields.data); //→nullが入ってくる
  const validatedData = validatedFields.data;

  // レスポンスがない場合のエラー処理
  if (!responseData) {
    return {
      ...validatedData,
      zodErrors: null,
      registerErrors: null,
      message: 'サーバーからのレスポンスがありません',
    };
  }

  // データ登録成功時の処理
  // MEMO: 通常であれば、完了ページにリダイレクトするなどの処理を行う
  redirect('/completed');
}

データ登録エラーが発生した場合のエラー処理

  • sleep関数を調整
  • registerDB 関数がエラーを返すように調整

/src/app/data/actions/registerUser-actions.ts

'use server';

const sleep = (ms: number) =>
  new Promise((_, reject) =>
    setTimeout(() => reject('データ登録中に何らかのエラーが発生しました'), ms)
  );

// ダミーのAPI
const registerDB = async (data: ProfileFormProps) => {
  try {
    await sleep(2000);

    return { data: data, error: null };
  } catch (error) {
    return { data: null, error: error as RegisterErrors };
  }
};

// ...

export async function registerUser(
  prevState: RegisterState,
  formData: FormData
): Promise<RegisterState> {
  const validatedFields = schemaRegister.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
  });

  // 本来であればここでAPIを叩いてユーザー登録処理を行う
  // 今回はダミーのAPIを呼び出す
  const responseData = await registerDB(validatedFields.data);
  const validatedData = validatedFields.data;

  // レスポンスがない場合のエラー処理
  if (!responseData) {
    return {
      ...validatedData,
      zodErrors: null,
      registerErrors: null,
      message: 'サーバーからのレスポンスがありません',
    };
  }

  // データ登録エラーが発生した場合のエラー処理
  if (responseData.error) {
    return {
      ...validatedData,
      zodErrors: null,
      registerErrors: responseData.error,
      message: 'データ登録エラーが発生しました。',
    };
  }

  // データ登録成功時の処理
  // MEMO: 通常であれば、完了ページにリダイレクトするなどの処理を行う
  redirect('/completed');
}

終わりに

以上となります!最後まで呼んでいただきありがとうございます🙏
2024/6/21現在ではまだ正式にサポートされていませんが、正式サポートされたらぜひ利用してください!
今回の作成したものはこのリポジトリに入れています。
https://github.com/masaru-suzuki/server-action-form

私の苦悩が皆様のお役に少しでも立てたら幸いです❤️

Discussion