safe-action-hooks について
React クライアントコンポーネントから next-safe-action のアクションを呼び出し、結果と状態を扱う Hooks です。通常の action と stateAction に対応し、上書き型とキュー処理型の楽観的更新を使い分けられます。
主な機能
- 01action と stateAction 用の 4 種の Hook
- 02実行状態と結果の追跡
- 03楽観的な状態更新
- 04累積する変更のキュー処理
- 05初期結果の設定とリセット
活用シーン
- 01インタラクティブな UI からのアクション実行
- 02stateAction による FormData の送信
- 03表示状態の楽観的な置き換え
- 04重複する変更の順次適用
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ next-safe-action/ skills --skill safe-action-hooksインストール後、Claude Code で/safe-action-hooksを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
optimistic-updates.mdstatus-callbacks.mdthrow-on-navigation.mduse-action.mduse-optimistic-state-action.mduse-state-action.md詳細
next-safe-action React Hooks
インポート
// All hooks
import {
useAction,
useOptimisticAction,
useStateAction,
useOptimisticStateAction,
} from "next-safe-action/hooks";
// Backward-compatible re-export (same useStateAction hook)
import { useStateAction } from "next-safe-action/stateful-hooks";
使用するフック
| フック | アクションメソッド | 用途 |
|---|---|---|
useAction | .action() | プログラムによるトリガー、インタラクティブな UI、ほとんどの場合 |
useOptimisticAction | .action() | 状態を置き換える変更(後勝ち)を即座に UI に反映する場合 |
useStateAction | .stateAction() | <form action={formAction}>、サーバーでの prevResult、キューに登録されたディスパッチ |
useOptimisticStateAction | .stateAction() | 累積する変更を即座に UI に反映する場合:重複する書き込みはキューに登録され、それぞれが確定済みの状態を基に処理されます |
まずアクションの種類で選択します。.action() には useAction/useOptimisticAction、.stateAction() には useStateAction/useOptimisticStateAction を組み合わせます。
useAction — クイックスタート
"use client";
import { useAction } from "next-safe-action/hooks";
import { createUser } from "@/app/actions";
export function CreateUserForm() {
const { execute, result, status, isExecuting, isPending } = useAction(createUser, {
onSuccess: ({ data }) => {
console.log("User created:", data);
},
onError: ({ error }) => {
console.error("Failed:", error.serverError);
},
});
return (
<form onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
execute({ name: formData.get("name") as string });
}}>
<input name="name" required />
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create User"}
</button>
{result.serverError && <p className="error">{result.serverError}</p>}
{result.data && <p className="success">Created: {result.data.id}</p>}
</form>
);
}
useOptimisticAction — クイックスタート
"use client";
import { useOptimisticAction } from "next-safe-action/hooks";
import { toggleTodo } from "@/app/actions";
export function TodoItem({ todo }: { todo: Todo }) {
const { execute, optimisticState } = useOptimisticAction(toggleTodo, {
currentState: todo,
updateFn: (state, input) => ({
...state,
completed: !state.completed,
}),
});
return (
<label>
<input
type="checkbox"
checked={optimisticState.completed}
onChange={() => execute({ todoId: todo.id })}
/>
{todo.title}
</label>
);
}
useOptimisticStateAction — クイックスタート
累積する必要がある重複した書き込み(アイテムを並べ替え、最初の保存が完了する前にもう一度並べ替える場合など)に使用します。ディスパッチはキューに登録され、それぞれが前の処理の完了を待ちます。サーバーには、最後に確定した状態が prevResult として渡されます。
"use client";
import { useOptimisticStateAction } from "next-safe-action/hooks";
import { moveItem } from "@/app/actions";
import { reorder } from "@/lib/reorder"; // the same reducer the server runs
export function ReorderList({ items }: { items: Item[] }) {
const { execute, optimisticState, isPending } = useOptimisticStateAction(moveItem, {
currentState: items, // must be a stable reference — compared by identity
updateFn: reorder,
});
return (
<ul data-saving={isPending}>
{optimisticState.map((item) => (
<li key={item.id}>
{item.label}
<button onClick={() => execute({ id: item.id, direction: "up" })}>Up</button>
</li>
))}
</ul>
);
}
アクションは .stateAction() でなければならず、ページに表示する状態を再検証する必要があります。
"use server";
export const moveItem = actionClient
.inputSchema(moveSchema)
.stateAction(async ({ parsedInput }, { prevResult }) => {
const next = reorder(prevResult.data!, parsedInput); // always the last confirmed state
await db.items.save(next);
revalidatePath("/items");
return next;
});
選択ルール、保留中の変更リストの形式、注意点については、useOptimisticStateAction の詳細を参照してください。
useStateAction — クイックスタート
"use client";
import { useStateAction } from "next-safe-action/hooks";
import { submitFeedback } from "@/app/actions";
export function FeedbackForm() {
const { formAction, result, isPending, hasSucceeded } = useStateAction(submitFeedback, {
onSuccess: ({ data }) => {
console.log("Submitted:", data);
},
onError: ({ error }) => {
console.error("Failed:", error.serverError);
},
});
return (
<form action={formAction}>
<input name="rating" type="number" min="1" max="5" required />
<textarea name="comment" required />
<button type="submit" disabled={isPending}>
{isPending ? "Submitting..." : "Submit"}
</button>
{result.validationErrors?.comment && (
<p className="error">{result.validationErrors.comment._errors[0]}</p>
)}
{hasSucceeded && <p className="success">Thank you!</p>}
</form>
);
}
サーバー側のアクションでは .stateAction() を使用する必要があります(.action() ではありません)。<form action={formAction}> は生の FormData を送信するため、入力スキーマは FormData を解析できる必要があります(例:zod-form-data を使用)。
"use server";
import { z } from "zod";
import { zfd } from "zod-form-data";
import { actionClient } from "@/lib/safe-action";
export const submitFeedback = actionClient
.inputSchema(
zfd.formData({
rating: zfd.numeric(z.number().min(1).max(5)),
comment: zfd.text(z.string()),
})
)
.stateAction(async ({ parsedInput }, { prevResult }) => {
// prevResult contains the previous SafeActionResult
await db.feedback.create({ data: parsedInput });
return { rating: parsedInput.rating };
});
戻り値
すべてのフック(useAction、useOptimisticAction、useStateAction、useOptimisticStateAction)は、次の値を返します。
| プロパティ | 型 | 説明 |
|---|---|---|
execute(input) | (input) => void | 結果を待たずに実行します |
executeAsync(input) | (input) => Promise<Result> | 結果を含むプロミスを返します |
input | Input | undefined | 最後にディスパッチされた入力(execute、executeAsync、または formAction 経由) |
result | SafeActionResult | 直近のアクション結果。4 つの分岐(idle/success/serverError/validationErrors)からなる判別可能なユニオンです。status または任意の has* 短縮プロパティを確認すると、型が絞り込まれます |
reset() | () => void | クライアントの状態を初期状態に戻し(initResult が指定されている場合は復元します)、実行中の処理の結果を無視します。すでに実行中のサーバー呼び出しはキャンセルしません。キューに入るフック(useStateAction、useOptimisticStateAction)では、順番を待っているすべてのディスパッチもスキップします。そのアクションは実行されず、書き込みも行われません |
status | HookActionStatus | 現在のステータス文字列 |
isIdle | boolean | 実行がまだ開始されていません |
isExecuting | boolean | アクションのプロミスが保留中です |
isTransitioning | boolean | React のトランジションが保留中です |
isPending | boolean | isExecuting || isTransitioning。ただし、reset() の後は、割り込まれたキャンセル不能のトランジションがまだ完了していなくても、リセット直後にアイドル状態になります |
hasSucceeded | boolean | 直近の実行がエラーなく完了しました(戻り値のないアクションは、result.data が undefined のままでも成功します) |
hasErrored | boolean | 直近の実行で serverError、validationErrors が発生したか、例外がスローされました(そのまま例外がスローされた場合、result は空のままです) |
hasNavigated | boolean | 直近の実行でナビゲーションが発生しました |
useOptimisticAction は、さらに次の値を返します。
| optimisticState | State | 楽観的に更新された状態 |
useStateAction は、さらに次の値を返します。
| formAction | (input) => void | <form action={formAction}> パターン用のディスパッチャー |
useOptimisticStateAction は、useStateAction が返すすべての値に加えて、次の値を返します。
| optimisticState | State | 確定済みの状態に実行中の変更をすべて反映した状態(常に定義されています) |
フックの戻り値自体が、status およびすべての has*/is* 短縮プロパティをキーとする判別可能なユニオンです(各プロパティは分岐ごとにリテラル型の true/false として型付けされています)。いずれかの判別子で型を絞り込むと、result も絞り込まれます。たとえば、if (hasSucceeded) の内部では、result.data は Data | undefined ではなく Data になります。フックのステータスによる型の絞り込みを参照してください。
initResult オプション
すべてのフックで initResult を指定して、事前に読み込んだ結果(たとえばサーバー上で取得したデータ)を初期値として設定できます。useAction/useStateAction では opts オブジェクトに、useOptimisticAction/useOptimisticStateAction では(currentState/updateFn と並ぶ)utils オブジェクトに指定します。この値は(React の useActionState の初期状態と同様に)マウント時に一度だけ取り込まれます。後からオプションを変更しても反映されず、reset() するとマウント時の値に戻ります。設定した形状は、idle 分岐の result の型に正確に反映されます。詳しくはinitResult の詳細を参照してください。
関連ドキュメント
- execute と executeAsync の違い、結果の処理
- useStateAction の詳細(判断表、formAction)
- useOptimisticAction による楽観的更新
- useOptimisticStateAction:重複する書き込みに対するキュー方式の楽観的更新
- ステータスのライフサイクルとすべてのコールバック
- throwOnNavigation フラグ
避けるべきパターン
// BAD: Using executeAsync without try/catch when navigation errors are possible
const handleClick = async () => {
const result = await executeAsync({ id }); // Throws on redirect!
showToast(result.data);
};
// GOOD: Wrap executeAsync in try/catch
const handleClick = async () => {
try {
const result = await executeAsync({ id });
showToast(result.data);
} catch (e) {
// Handle non-navigation errors here if needed, then re-throw
// Navigation errors must propagate to Next.js
throw e;
}
};
// BAD: Last-write-wins for changes that accumulate — the second move builds on stale state,
// and the response of the first move is discarded
const { execute } = useOptimisticAction(moveItem, { currentState: items, updateFn: reorder });
// GOOD: Queued dispatches, each folding over the previous result
const { execute } = useOptimisticStateAction(moveItem, { currentState: items, updateFn: reorder });
// BAD: Using .action() with useStateAction — type error
const myAction = actionClient.inputSchema(schema).action(async ({ parsedInput }) => { ... });
useStateAction(myAction); // TypeScript error!
// GOOD: Use .stateAction() for useStateAction
const myAction = actionClient.inputSchema(schema).stateAction(async ({ parsedInput }, { prevResult }) => { ... });
useStateAction(myAction); // Works!