createForm API
The factory that turns a Zod schema into a typed form with auto-rendered fields, built-in validation, and a wired action.
Returns
createForm returns three typed pieces that all share the same internal form store:
| Export | Description |
|---|---|
| Form | A <form> wrapper. Handles submit, runs validation, auto-renders schema fields when an adapter is set. Accepts className and optional children. |
| Field | Renders or overrides a single field. When an adapter is set, render is optional. Without an adapter, render is required — TypeScript enforces this distinction. |
| useFormState | Hook that exposes isSubmitting, result, and submitCount. Must be called inside the component tree that renders <Form>. |
Options
| Option | Type | Description |
|---|---|---|
schemarequired | ZodType | Zod object schema — single source of truth for fields, widget inference, and client-side validation. |
actionrequired | FormAction | Tagged server or client action from asServerAction / asClientAction. Formura detects the tag at runtime to route correctly. |
adapter | FormAdapter | UI adapter that renders fields automatically. Without it, every Field component requires a render prop — TypeScript enforces this. |
defaultValues | Partial<FormValues> | Initial field values. Keys must match schema fields. Omitted fields default to undefined. |
fields | Record<string, FieldConfig> | Per-field config (label, render, disabled, ErrorElement) without JSX. Overrides auto-generated defaults. JSX <Field> children take precedence over this. |
GlobalErrorElement | ComponentType<{ errorMessage: string }> | Default error display component for all fields. Per-field ErrorElement overrides this. |
onSuccess | (result: ActionSuccess<TData>) => void | Called when the action returns status: "success". |
onError | (result: ActionError) => void | Called when the action returns status: "error". |
onSettled | (result: ActionResult<TData>) => void | Called after every action completes, success or error. |
Full example
import { asClientAction, createForm } from "@formura/core";
import shadcnAdapter from "@formura/adapters/shadcn";
import { z } from "zod";
const demoSchema = z.object({
username: z.string().min(2),
email: z.email(),
role: z.enum(["developer", "designer", "manager"]),
terms: z.boolean().refine((v) => v, "You must accept the terms"),
});
const demoClientAction = asClientAction<typeof demoSchema>(
async (values) => {
if (values.username.toLowerCase() === "taken") {
return {
status: "error",
fieldErrors: { username: "Username already taken." },
};
}
return { status: "success", data: values };
},
);
export const { Form, Field, useFormState } = createForm({
schema: demoSchema,
adapter: shadcnAdapter,
action: demoClientAction,
defaultValues: {
username: "",
email: "",
role: "developer",
terms: false,
},
fields: {
terms: { label: "I accept the terms of service" },
},
onSuccess: (result) => console.log("created", result.data),
});Client-side validation
Formura validates values against your Zod schema before ever calling your action. If any field fails, errors are mapped directly onto the relevant fields — your action only runs when the entire schema passes. There is no way for an invalid submission to reach the server.
If your action returns fieldErrors, those are also mapped onto fields after the action completes. Both schema validation errors and server-returned errors flow through the same field error state.
Headless mode (no adapter)
Omit the adapter option and Formura becomes entirely headless. In headless mode, every <Field> requires a render prop — TypeScript enforces this by making render required on the Field type. You control every pixel.
"use client";
import { createForm } from "@formura/core";
import { z } from "zod";
import { myAction } from "./actions";
const schema = z.object({
username: z.string().min(2),
email: z.email(),
});
// No adapter — Field.render is required (TypeScript error if omitted)
const { Form, Field, useFormState } = createForm({
schema,
action: myAction,
});
export const HeadlessSignup = () => {
const { isSubmitting, result } = useFormState();
return (
<Form>
<Field
name="username"
label="Username"
render={({ field }) => (
<input
name={field.name}
value={field.value as string}
onChange={field.onChange}
disabled={field.disabled}
style={{ border: "1px solid gray", padding: "4px 8px" }}
/>
)}
/>
<Field
name="email"
label="Email"
render={({ field }) => (
<input
type="email"
name={field.name}
value={field.value as string}
onChange={field.onChange}
/>
)}
/>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Submitting..." : "Sign up"}
</button>
{result?.status === "success" && <p>Done!</p>}
</Form>
);
};The render prop receives a field object with name, value, onChange, onBlur, and disabled. Validation and action routing work identically regardless of whether an adapter is used.
Next
Fields
Auto-render, overrides, custom render props, and error display.