Next.jsのuseActionStateを使ったトースト通知。私の書き方

先日、Xにて下記の投稿を見つけました！

https://x.com/rwieruch/status/1897686642732785927?s=46

ReactのuseActionState（フックの詳細は以下リンク）と、shadcn/uiのtoast（詳細は以下リンク）を使った実装をされています。

useActionState
https://ja.react.dev/reference/react/useActionState

toast
https://ui.shadcn.com/docs/components/sonner

Xの実装素晴らしいのですが、私の実装も紹介したいと思い書いてみました。

## 処理の流れ

1.フォーム入力後、submitボタンを押下
2.form にactin={Action}と記載しているため、useActionStateの処理が始まります。
3.フォーム内のname属性を持つ要素の値をobject形式で取得し、FormDataとしてActionに渡される
4.zodでのバリデーション
5.バックエンドにリクエスト→レスポンス返ってくる
6.正常に処理が終わればtoasterで通知

上記の処理の間、pendingがtrueになりますので、送信中..の表示に変わります。
処理が終われば、falseに変わります、

### まずは、useActionStateを使ったフォームづくりです。

下記のように実装します。
```typescript
// src/app/signup/page.tsx
  return (
    <div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 p-4">
      <div className="w-full max-w-md p-8 space-y-8 bg-white rounded-lg shadow-md">
        <div className="text-center">
          <h1 className="text-3xl font-bold text-gray-900">アカウント作成</h1>
          <p className="mt-2 text-sm text-gray-600">必要情報を入力してアカウントを作成してください</p>
        </div>
        // action={formAction}を入れる必要があります。
        <form action={formAction} className="space-y-6">
          <div className="space-y-2">
            <Label htmlFor="username" className="text-sm font-medium">ユーザー名</Label>
            <Input 
              type="text" 
              name="username" 
              id="username"
              placeholder="ユーザー名を入力" 
              className="w-full px-3 py-2 border rounded-md" 
              required 
            />
          </div>
          
          <div className="space-y-2">
            <Label htmlFor="email" className="text-sm font-medium">メールアドレス</Label>
            <Input 
              type="email" 
              name="email" 
              id="email"
              placeholder="example@example.com" 
              className="w-full px-3 py-2 border rounded-md" 
              required 
            />
          </div>
          
          <div className="space-y-2">
            <Label htmlFor="password" className="text-sm font-medium">パスワード</Label>
            <Input 
              type="password" 
              name="password" 
              id="password"
              placeholder="8文字以上で入力" 
              className="w-full px-3 py-2 border rounded-md" 
              required 
            />
          </div>
          
          <div className="space-y-2">
            <Label htmlFor="confirmPassword" className="text-sm font-medium">パスワード（確認）</Label>
            <Input 
              type="password" 
              name="confirmPassword" 
              id="confirmPassword"
              placeholder="同じパスワードを再入力" 
              className="w-full px-3 py-2 border rounded-md" 
              required 
            />
          </div>
          //　submitされることで、firnActionが動きます。
          <Button 
            type="submit"
            className="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md transition duration-200" 
            disabled={isPending}
          >
            アカウント作成 {isPending && "..."}
          </Button>
        </form>
        
        <div className="mt-6 text-center text-sm">
          <p className="text-gray-600">
            すでにアカウントをお持ちですか？{" "}
            <a href="/login" className="font-medium text-blue-600 hover:text-blue-500">
              ログイン
            </a>
          </p>
        </div>
      </div>
    </div>
  );
};
export default Signup;
```
### 続いて、useActionStateのActionを使っていきます。
今回は処理の後半にtoasterを表示させる実装をするのでその部分はクライアントコンポーネントに書いていきます。

```typescript
// src/app/signup/page.tsx
"use client";

import React from "react";
import { useActionState } from "react";
import { signupServerAction } from "./action";
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button";
import { toast } from "sonner"

  const signupAction = async (state: void | null, formData: FormData) => {
    const result = await signupServerAction(formData);
    if (result.success) {
      toast.success("登録に成功しました");
    } else {
      toast.error("登録に失敗しました");
    }
  };

  const [, formAction, isPending] = useActionState(signupAction, null);
```

### バックエンドのエンドポイントとのやり取りはサーバコンポーネントに書きます。
※本来ならzodでバリデーションを書いたり、ほかにもいろいろ書きますが、今回はサンプルなので必要最小限にしています。
```typescript
// src/app/signup/action.ts
"use server";

import { apiClient } from "@/lib/api-client";

type SignupResponse = {
  userId?: string;
  message?: string;
};

export const signupServerAction = async (formData: FormData) => {
  const username = formData.get("username") as string;
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;

  // バリデーション
  if (!username || !password || !email) {
    return { success: false, message: "ユーザー名とパスワードとメールアドレスは必須です" };
  }

  try {
    // APIを呼び出し
    const response = await apiClient.post<SignupResponse>('/users/register', { 
      username, 
      password,
      email
    });

    if (response.error) {
      return { success: false, message: response.error };
    }

    return { success: true, message: "登録に成功しました", data: response.data };
  } catch {
    return { success: false, message: "サーバーエラーが発生しました" };
  }
};


```
これで完成です！

成功すれば下記のように動きます。
[![Image from Gyazo](https://i.gyazo.com/0e10f1e372fe0f5754c020578f40c2b3.gif)](https://gyazo.com/0e10f1e372fe0f5754c020578f40c2b3)