【Next.js15 / React19】useActionState 使い倒してみた
イントロダクション
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まではuseFormState
やuseFormStates
といった(正直使いづらいゴホンゴホン)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は使われるかと思いますが、率直な感想を述べると、
良かった点 👍
- コード量の削減:従来のhooksやカスタムフックと比べて、コード量がかなり減りそうです。
- 状態管理の一元化:ローディング状態、エラー処理、成功処理を全て一箇所で管理できるのが嬉しいですね。
- 型安全性:Typescriptとは親友になりましょう。
- パフォーマンス:Server Actionsと組み合わせられるので、不要なレンダリングをスキップできそうですね。
苦労しそうな点 🤔
- デバッグの難しさ:フォーム自体はクライアントサイド、アクションはサーバサイドなので、エラーの切り分けは手がかかりそうですね。
- 設計の考慮:CRUD等の複数のアクションを組み合わせる場合、状態の管理方法をしっかり設計しないとコードが煩雑になりかねないなと感じます。
- 学習コスト:当然ながら新しい概念なので、チームメンバー全員が理解するまでに少し時間がかかりました。(ちなみに私も
INITIAL_STATE
ナニソレオイシイノ?ってなってました)
まとめ
React19のuseActionState
は、特にNext.js15のServer Actionsと組み合わせると非常に強力です。従来のような複雑な状態管理やローディング/エラー処理のコードを大幅に削減できます。
最初は「また新しいHookか...」と思いましたが、特にフォーム処理が多いアプリケーションでは、導入する価値が非常に高いと感じました。
新しい技術は最初は覚えるのが大変ですが、一度マスターすれば開発効率が大きく向上しますね。
風の噂ですがuseOptimistic
なんていうhookもでたとかなんとか、、、
え?useオプティミスティック...?もちろん知ってます!今から調べます!
...という新たな調査が始まりそうです。またの機会に共有できればと思います!
参考資料
React19公式ドキュメント
Next.js15公式ドキュメント
React 19の新機能まるわかり
最後まで読んでいただき、ありがとうございました!
Discussion