skills/next-safe-action/skills/safe-action-hooks

safe-action-hooks

Installation
SKILL.md

next-safe-action React Hooks

Import

// All hooks
import { useAction, useOptimisticAction, useStateAction } from "next-safe-action/hooks";

// Backward-compatible re-export (same useStateAction hook)
import { useStateAction } from "next-safe-action/stateful-hooks";

useAction — Quick Start

"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 — Quick Start

"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>
  );
}

useStateAction — Quick Start

"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>
  );
}

The server-side action must use .stateAction() (not .action()):

"use server";

import { z } from "zod";
import { actionClient } from "@/lib/safe-action";

export const submitFeedback = actionClient
  .inputSchema(z.object({ rating: z.number().min(1).max(5), comment: z.string() }))
  .stateAction(async ({ parsedInput }, { prevResult }) => {
    // prevResult contains the previous SafeActionResult
    await db.feedback.create({ data: parsedInput });
    return { rating: parsedInput.rating };
  });

Return Value

All hooks (useAction, useOptimisticAction, useStateAction) return:

Property Type Description
execute(input) (input) => void Fire-and-forget execution
executeAsync(input) (input) => Promise<Result> Returns a promise with the result
input Input | undefined Last input passed to execute
result SafeActionResult Last action result — discriminated union of 4 branches (idle / success / serverError / validationErrors); narrowed when you check status or any has* shorthand
reset() () => void Resets all state to initial values
status HookActionStatus Current status string
isIdle boolean No execution has started yet
isExecuting boolean Action promise is pending
isTransitioning boolean React transition is pending
isPending boolean isExecuting || isTransitioning
hasSucceeded boolean Last execution returned data
hasErrored boolean Last execution had an error
hasNavigated boolean Last execution triggered a navigation

useOptimisticAction additionally returns: | optimisticState | State | The optimistically-updated state |

useStateAction additionally returns: | formAction | (input) => void | Dispatcher for <form action={formAction}> pattern |

The hook return is itself a discriminated union keyed on status and every has* / is* shorthand (each typed as literal true / false per branch). Narrowing any discriminant narrows result — e.g. inside if (hasSucceeded), result.data is Data (not Data | undefined). See Type narrowing via hook status.

Supporting Docs

Anti-Patterns

// 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: 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!
Weekly Installs
363
First Seen
Today