FFormura

Actions

Formura routes your submit handler automatically — tag it once and forget about wiring.

How actions work

Actions are tagged functions. Tagging is what lets Formura route them correctly at runtime — a _tag property is attached so the form engine knows whether to call it as a server action (via useFormState / React transitions) or invoke it directly as a client function.

Validation flow

Understanding the exact submit sequence prevents confusion about when your action runs and where errors come from:

  1. 1

    Client-side validation

    Formura validates all field values against your Zod schema before any network call. This happens entirely in the browser.

  2. 2

    Validation fails → field errors, stop

    If validation fails, errors are mapped onto the relevant fields immediately. The action is never called and isSubmitting returns to false.

  3. 3

    Validation passes → call action

    For server actions, values are serialized to FormData automatically. For client actions, they are passed as a typed object. Your action is called.

  4. 4

    Action returns ActionResult

    If the result contains fieldErrors, they are mapped onto field error state — overwriting any previous validation errors for those fields. The result is stored and useFormState().result updates.

  5. 5

    Lifecycle callbacks fire

    onSuccess, onError, or onSettled are called with the result, depending on the status.

Server vs client actions

Pick the action type based on what your handler needs to do:

Server ActionClient Action
Import@formura/core/server@formura/core
ReceivesprevState, formData (raw FormData)values (typed object), formData
File directiveMust be in a "use server" fileWorks anywhere, including client components
Use whenDatabase writes, auth, server-only secrets, revalidationREST API calls, client-side logic, no server needed
Requires Next.js?Yes (14+)No — works with any React setup

Server Actions

Use asServerAction from @formura/core/server in a file marked "use server". The handler receives prevState (the last ActionResult or null) and raw formData. Parse values yourself or let the schema handle it.

actions.ts
"use server";

import type { ActionResult } from "@formura/core";
import { asServerAction } from "@formura/core/server";

type SignupData = { userId: string };

export const signupAction = asServerAction<SignupData>(
  async (_prevState, formData): Promise<ActionResult<SignupData>> => {
    const username = String(formData.get("username") ?? "");

    if (username === "admin") {
      return {
        status: "error",
        message: "Database rejection",
        fieldErrors: { username: "This handle is strictly reserved." },
      };
    }

    // Do your DB write, send email, etc.
    return {
      status: "success",
      data: { userId: "user_99a8f" },
    };
  },
);

Client Actions

Use asClientAction when you want typed, validated values instead of raw FormData — perfect for REST APIs, third-party SDKs, or any client-side flow that does not need a server boundary.

client-action.ts
import { asClientAction } from "@formura/core";
import { z } from "zod";

const schema = z.object({
  username: z.string().min(2),
  email: z.email(),
});

// values is typed as { username: string; email: string }
export const clientAction = asClientAction<typeof schema>(
  async (values, formData) => {
    const res = await fetch("/api/signup", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(values),
    });

    if (!res.ok) {
      return {
        status: "error",
        fieldErrors: { username: "Already taken." },
      };
    }

    return { status: "success", data: await res.json() };
  },
);

ActionResult

Every action must return one of two shapes. The TData generic on the success branch flows through to useFormState().result:

// Success — data is typed as TData
{ status: "success", data?: TData, message?: string }

// Error — fieldErrors keys are plain strings, not typed to the schema
{ status: "error", message?: string, fieldErrors?: Record<string, string> }
StatusFieldsNotes
"success"data?, message?Triggers onSuccess. Result available in useFormState().result.
"error"message?, fieldErrors?fieldErrors keys are mapped onto field error state. Triggers onError.

useFormState

Read submission state anywhere inside your form tree. The hook reads from the same context the <Form> component provides — it will throw if used outside a Form:

signup-form.tsx
const { Form, useFormState } = createForm({ ... });

const SubmitButton = () => {
  const { isSubmitting, result, submitCount } = useFormState();

  return (
    <>
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Submitting..." : "Sign up"}
      </button>

      {result?.status === "success" && (
        <p className="text-green-500">
          Account created! ID: {result.data?.userId}
        </p>
      )}

      {result?.status === "error" && result.message && (
        <p className="text-red-500">{result.message}</p>
      )}

      <p className="text-xs text-muted-foreground">
        Submitted {submitCount} time{submitCount !== 1 ? "s" : ""}
      </p>
    </>
  );
};
PropertyTypeDescription
isSubmittingbooleanTrue while the action is in flight. False during client-side validation failures.
resultActionResult<TData> | nullThe last ActionResult returned by your action. null before first submission.
submitCountnumberTotal number of submissions including validation-failed ones.

Lifecycle callbacks

Pass optional callbacks to createForm to react to action outcomes outside the form component — for toast notifications, redirects, analytics, etc.:

createForm({
  schema,
  action,
  adapter: shadcnAdapter,
  onSuccess: (result) => {
    toast.success("Account created!");
    router.push("/dashboard");
  },
  onError: (result) => {
    toast.error(result.message ?? "Something went wrong.");
  },
  onSettled: (result) => {
    analytics.track("form_submitted", { status: result.status });
  },
});

Callbacks are defined at factory time, not per-render. They receive the full ActionResult object with the typed data or fieldErrors.

Next

createForm API

Every option on createForm, explained.