【Next.js15 / React19】useActionState 使い倒してみた

2025/03/18に公開
3

イントロダクション

Next.js15がstableになってからしばらく経ちますが、ついに先日、社内で使用しているNext.jsのテンプレートをv15にアップグレードしました!

何を隠そう、ずっと使いたかったんです。
useActionState を!

キャッシュのデフォルト無効化やTurbopackの導入など盛りだくさんのNext.js15ですが、
個人的には「useActionState × ServerActions」を最も楽しみにしていました。

Next.js15とReact19の新機能であるuseActionStateを調査しながら、最新技術のキャッチアップをしていきたいと思っています。

useActionStateとは?

useActionState(参考)はReact19で導入された新しいHookで、ServerActionsの実行状態を管理するために使用します。Next.js15ではこの機能がシームレスに統合されています。
簡単に言うと:

  • フォームの送信状態(ローディング、エラー、成功)を簡単に管理できる
  • ServerActionsの結果を直接UIに反映できる
  • クライアントサイドの状態管理コードを大幅に削減できる

激アツですねー
Next.js14、React18まではuseFormStateuseFormStatesといった(正直使いづらいゴホンゴホン)Hooksで管理していましたが、ほぼほぼこのuseActionStateに置き換えられそうですね。

正直、最初は「また新しいHookか...覚えきれないよ...」と思いましたが、使ってみたらめちゃくちゃ便利でした!

基本的な使い方

まずは基本的な使い方から見ていきましょう。以下は最もシンプルな例です:

'use client';

import { useActionState } from 'react';
import { addTodo } from './actions';

const TodoForm = () => {
  const [state, action] = useActionState(addTodo, null);
  
  return (
    <form action={action}>
      <input name="todo" type="text" />
      <button type="submit" disabled={state.pending}>
        {state.pending ? '追加中...' : 'ToDoを追加'}
      </button>
      {state.error && <p>エラー: {state.error.message}</p>}
      {state.data && <p>追加しました: {state.data.text}</p>}
    </form>
  );
};

export default TodoForm;

対応するServer Actionはこんな感じです:

// actions.ts
'use server';

export async function addTodo(prevState: any, formData: FormData) {
  try {
    const text = formData.get('todo') as string;
    // バリデーションをかけたり
    // DBへ保存したり

    // 成功したら新しいToDoを返す
    return { text: newTodo.text }; // ダミーです
  } catch (error: any) {
    // エラーを返す
    return { error: error.message };
  }
}

この例では、useActionStateフックを使って:

フォームの送信状態を管理している(state.pending
エラーがあれば表示している(state.error
成功したら結果を表示している(state.data

これだけでローディング状態、エラーハンドリング、成功時の処理が全部できちゃいます。便利すぎませんか!?

もっと実践的な例

ここからは、もう少し実践的な例を見ていきましょう。ユーザー登録フォームを作ってみます:

'use client';

import { useActionState } from 'react';
import { registerUser } from './user-actions';

const RegistrationForm = () => {
  const [state, action] = useActionState(registerUser, null);
  
  // 送信が成功したかどうかをチェック
  const isSuccess = state?.data?.success;
  
  // すでに成功している場合は成功メッセージを表示
  if (isSuccess) {
    return (
      <div className="success-message">
        <h2>登録完了!</h2>
        <p>ようこそ、{state.data.username}さん!</p>
        <p>確認メールを{state.data.email}に送信しました。</p>
      </div>
    );
  }
  
  return (
    <form action={action} className="registration-form">
      <div className="form-field">
        <label htmlFor="username">ユーザー名</label>
        <input 
          id="username" 
          name="username" 
          type="text" 
          required 
          disabled={state.pending}
        />
      </div>
      
      <div className="form-field">
        <label htmlFor="email">メールアドレス</label>
        <input 
          id="email" 
          name="email" 
          type="email" 
          required 
          disabled={state.pending}
        />
      </div>
      
      <div className="form-field">
        <label htmlFor="password">パスワード</label>
        <input 
          id="password" 
          name="password" 
          type="password" 
          required 
          disabled={state.pending}
        />
      </div>
      
      <button 
        type="submit" 
        disabled={state.pending} 
        className="submit-button"
      >
        {state.pending ? '登録中...' : '登録する'}
      </button>
      
      {state.error && (
        <div className="error-message">
          <p>エラーが発生しました:{state.error.message}</p>
        </div>
      )}
    </form>
  );
};

export default RegistrationForm;

対応するServer Actionはこちら:

// user-actions.ts
'use server';

type RegistrationResult = {
  success?: boolean;
  username?: string;
  email?: string;
  error?: string;
};

export async function registerUser(
  prevState: any, 
  formData: FormData
): Promise<RegistrationResult> {
  try {
    // フォームからデータを取得
    const username = formData.get('username') as string;
    const email = formData.get('email') as string;
    const password = formData.get('password') as string;
    
    // バリデーション
    if (!username || username.length < 3) {
      throw new Error('ユーザー名は3文字以上入力してください');
    }
    
    if (!email || !email.includes('@')) {
      throw new Error('有効なメールアドレスを入力してください');
    }
    
    if (!password || password.length < 8) {
      throw new Error('パスワードは8文字以上入力してください');
    }
    
    // ここで実際のユーザー登録処理を行う
    // 例: データベースへの保存、メール送信など
    
    // ダミー遅延
    await new Promise(resolve => setTimeout(resolve, 1500));
    
    // 成功時のレスポンス
    return {
      success: true,
      username,
      email,
    };
  } catch (error: any) {
    // エラー時のレスポンス
    return {
      error: error.message,
    };
  }
}

この例では、登録が成功したかどうかで表示を切り替えていますし、ローディング中はフォーム入力を無効にしています。実際のアプリケーションではさらに複雑な処理が入るでしょうが、基本的な流れはこれで十分です!

応用テクニック:複数のアクションを組み合わせる

一つのフォームではなく、いくつかの操作を持つUIコンポーネントでもuseActionStateは活躍します。例えば、ToDoリストの表示と更新を組み合わせてみましょう:

'use client';

import { useActionState } from 'react';
import { addTodo, toggleTodo, deleteTodo } from './todo-actions';

const TodoManager = () => {
  // それぞれのアクションに対して別々のuseActionStateを使用
  const [addState, addAction] = useActionState(addTodo, null);
  const [toggleState, toggleAction] = useActionState(toggleTodo, null);
  const [deleteState, deleteAction] = useActionState(deleteTodo, null);
  
  // 初期ToDoリスト(実際はデータベースから取得したりします)
  const initialTodos = [
    { id: 1, text: '牛乳を買う', completed: false },
    { id: 2, text: '報告書を書く', completed: true },
  ];
  
  // 現在のToDoリスト(各アクションの結果を反映)
  const todos = addState?.data?.todos || 
                toggleState?.data?.todos || 
                deleteState?.data?.todos || 
                initialTodos;
  
  return (
    <div className="todo-manager">
      <h2>ToDoリスト</h2>
      
      {/* 追加フォーム */}
      <form action={addAction}>
        <input name="text" type="text" placeholder="新しいToDoを入力" />
        <button 
          type="submit" 
          disabled={addState.pending}
        >
          {addState.pending ? '追加中...' : '追加'}
        </button>
        {addState.error && <p className="error">{addState.error.message}</p>}
      </form>
      
      {/* ToDoリスト */}
      <ul className="todo-list">
        {todos.map(todo => (
          <li key={todo.id} className={todo.completed ? 'completed' : ''}>
            {/* 完了トグルフォーム */}
            <form action={toggleAction}>
              <input type="hidden" name="id" value={todo.id} />
              <button 
                type="submit" 
                className="toggle-btn"
                disabled={toggleState.pending && toggleState.formData?.get('id') === String(todo.id)}
              >
                {todo.completed ? '✓' : '○'}
              </button>
            </form>
            
            <span>{todo.text}</span>
            
            {/* 削除フォーム */}
            <form action={deleteAction}>
              <input type="hidden" name="id" value={todo.id} />
              <button 
                type="submit" 
                className="delete-btn"
                disabled={deleteState.pending && deleteState.formData?.get('id') === String(todo.id)}
              >
                ×
              </button>
            </form>
          </li>
        ))}
      </ul>
      
      {/* 全体の操作状態 */}
      {(addState.pending || toggleState.pending || deleteState.pending) && (
        <div className="loading-indicator">処理中...</div>
      )}
    </div>
  );
};

export default TodoManager;

対応するServer Actions:

// todo-actions.ts
'use server';

// サンプル用のインメモリデータベース
let todos = [
  { id: 1, text: '牛乳を買う', completed: false },
  { id: 2, text: '報告書を書く', completed: true },
];

// ToDoを追加するアクション
export async function addTodo(prevState: any, formData: FormData) {
  try {
    const text = formData.get('text') as string;
    
    if (!text || text.trim().length === 0) {
      throw new Error('ToDoを入力してください');
    }
    
    // 新しいToDoを作成
    const newTodo = {
      id: Date.now(),
      text,
      completed: false,
    };
    
    // ToDoリストに追加
    todos = [...todos, newTodo];
    
    // ダミー遅延
    await new Promise(resolve => setTimeout(resolve, 500));
    
    return { todos };
  } catch (error: any) {
    return { error: { message: error.message } };
  }
}

// ToDoの完了状態を切り替えるアクション
export async function toggleTodo(prevState: any, formData: FormData) {
  try {
    const id = Number(formData.get('id'));
    
    if (!id) {
      throw new Error('IDが無効です');
    }
    
    // 該当するToDoの完了状態を切り替え
    todos = todos.map(todo => {
      if (todo.id === id) {
        return { ...todo, completed: !todo.completed };
      }
      return todo;
    });
    
    // ダミー遅延
    await new Promise(resolve => setTimeout(resolve, 300));
    
    return { todos };
  } catch (error: any) {
    return { error: { message: error.message } };
  }
}

// ToDoを削除するアクション
export async function deleteTodo(prevState: any, formData: FormData) {
  try {
    const id = Number(formData.get('id'));
    
    if (!id) {
      throw new Error('IDが無効です');
    }
    
    // 該当するToDoを削除
    todos = todos.filter(todo => todo.id !== id);
    
    // ダミー遅延
    await new Promise(resolve => setTimeout(resolve, 300));
    
    return { todos };
  } catch (error: any) {
    return { error: { message: error.message } };
  }
}

応用テクニック:Zodと組み合わせる

useActionStateの素敵なところは、他のライブラリと組み合わせて使いやすいことです。特に型安全なバリデーションライブラリである「Zod」と組み合わせることは容易に考えられますね。
上記のaddTodoをTypescript × zodで書き直してみましょう。

// todo-actions.ts
'use server';

import { z } from 'zod';

// サンプル用のインメモリデータベース
let todos = [
  { id: 1, text: '牛乳を買う', completed: false },
  { id: 2, text: '報告書を書く', completed: true },
];

// ToDoの型定義
type Todo = {
  id: number;
  text: string;
  completed: boolean;
};

// 入力スキーマの定義
const todoInputSchema = z.object({
  text: z.string()
    .min(1, { message: 'ToDoを入力してください' })
    .max(100, { message: 'ToDoは100文字以内で入力してください' })
    .trim()
});

// レスポンスの型定義
type TodoResponse = {
  todos?: Todo[];
  error?: {
    message: string;
    fieldErrors?: Record<string, string[]>;
  };
};

export async function addTodo(prevState: any, formData: FormData): Promise<TodoResponse> {
  try {
    // FormDataから入力値を取得
    const rawInput = {
      text: formData.get('text'),
    };
    
    // Zodでバリデーション
    const validationResult = todoInputSchema.safeParse(rawInput);
    
    // バリデーションエラーがあればそれを返す
    if (!validationResult.success) {
      return {
        error: {
          message: 'バリデーションエラー',
          fieldErrors: validationResult.error.formErrors.fieldErrors,
        }
      };
    }
    
    // バリデーション済みデータを使用
    const { text } = validationResult.data;
    
    // 新しいToDoを作成
    const newTodo: Todo = {
      id: Date.now(),
      text,
      completed: false,
    };
    
    // ToDoリストに追加
    todos = [...todos, newTodo];
    
    // ダミー遅延
    await new Promise(resolve => setTimeout(resolve, 500));
    
    // 成功レスポンス
    return { todos };
  } catch (error: any) {
    // 予期しないエラー
    return {
      error: {
        message: error.message || 'エラーが発生しました',
      }
    };
  }
}

クロージング

これから様々なプロジェクトでこのhookは使われるかと思いますが、率直な感想を述べると、

良かった点 👍

  1. コード量の削減:従来のhooksやカスタムフックと比べて、コード量がかなり減りそうです。
  2. 状態管理の一元化:ローディング状態、エラー処理、成功処理を全て一箇所で管理できるのが嬉しいですね。
  3. 型安全性:Typescriptとは親友になりましょう。
  4. パフォーマンス:Server Actionsと組み合わせられるので、不要なレンダリングをスキップできそうですね。

苦労しそうな点 🤔

  1. デバッグの難しさ:フォーム自体はクライアントサイド、アクションはサーバサイドなので、エラーの切り分けは手がかかりそうですね。
  2. 設計の考慮:CRUD等の複数のアクションを組み合わせる場合、状態の管理方法をしっかり設計しないとコードが煩雑になりかねないなと感じます。
  3. 学習コスト:当然ながら新しい概念なので、チームメンバー全員が理解するまでに少し時間がかかりました。(ちなみに私もINITIAL_STATEナニソレオイシイノ?ってなってました)

まとめ

React19のuseActionStateは、特にNext.js15のServer Actionsと組み合わせると非常に強力です。従来のような複雑な状態管理やローディング/エラー処理のコードを大幅に削減できます。

最初は「また新しいHookか...」と思いましたが、特にフォーム処理が多いアプリケーションでは、導入する価値が非常に高いと感じました。
新しい技術は最初は覚えるのが大変ですが、一度マスターすれば開発効率が大きく向上しますね。

風の噂ですがuseOptimisticなんていうhookもでたとかなんとか、、、
え?useオプティミスティック...?もちろん知ってます!今から調べます!
...という新たな調査が始まりそうです。またの機会に共有できればと思います!

参考資料

React19公式ドキュメント
Next.js15公式ドキュメント
React 19の新機能まるわかり

最後まで読んでいただき、ありがとうございました!

3
BLT SDC Tech Blog

Discussion