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
Client-side validation
Formura validates all field values against your Zod schema before any network call. This happens entirely in the browser.
- 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
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
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
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 Action | Client Action | |
|---|---|---|
| Import | @formura/core/server | @formura/core |
| Receives | prevState, formData (raw FormData) | values (typed object), formData |
| File directive | Must be in a "use server" file | Works anywhere, including client components |
| Use when | Database writes, auth, server-only secrets, revalidation | REST 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.
"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.
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> }| Status | Fields | Notes |
|---|---|---|
| "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:
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>
</>
);
};| Property | Type | Description |
|---|---|---|
| isSubmitting | boolean | True while the action is in flight. False during client-side validation failures. |
| result | ActionResult<TData> | null | The last ActionResult returned by your action. null before first submission. |
| submitCount | number | Total 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.