FFormura

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:

ExportDescription
FormA <form> wrapper. Handles submit, runs validation, auto-renders schema fields when an adapter is set. Accepts className and optional children.
FieldRenders or overrides a single field. When an adapter is set, render is optional. Without an adapter, render is required — TypeScript enforces this distinction.
useFormStateHook that exposes isSubmitting, result, and submitCount. Must be called inside the component tree that renders <Form>.

Options

OptionTypeDescription
schemarequiredZodTypeZod object schema — single source of truth for fields, widget inference, and client-side validation.
actionrequiredFormActionTagged server or client action from asServerAction / asClientAction. Formura detects the tag at runtime to route correctly.
adapterFormAdapterUI adapter that renders fields automatically. Without it, every Field component requires a render prop — TypeScript enforces this.
defaultValuesPartial<FormValues>Initial field values. Keys must match schema fields. Omitted fields default to undefined.
fieldsRecord<string, FieldConfig>Per-field config (label, render, disabled, ErrorElement) without JSX. Overrides auto-generated defaults. JSX <Field> children take precedence over this.
GlobalErrorElementComponentType<{ errorMessage: string }>Default error display component for all fields. Per-field ErrorElement overrides this.
onSuccess(result: ActionSuccess<TData>) => voidCalled when the action returns status: "success".
onError(result: ActionError) => voidCalled when the action returns status: "error".
onSettled(result: ActionResult<TData>) => voidCalled after every action completes, success or error.

Full example

create-demo-form.ts
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.

headless-form.tsx
"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.