Skip to content

Forms

StateMesh forms are backed by mesh state. They support async field-level validation, schema adapters, server error mapping, dirty tracking, field arrays, autosave, and multi-step wizards.

Define a Form

ts
mesh.form("profile.form", {
  initialValues: {
    name: "",
    email: "",
    links: [] as Array<{ url: string }>
  },
  fields: {
    name(value) {
      return value.trim() ? null : "Name is required";
    },
    async email(value) {
      if (!value.includes("@")) return "Valid email is required";
      const available = await api.get<{ available: boolean }>("/users/email", {
        query: { email: value }
      });
      return available.available ? null : "Email is already taken";
    }
  },
  validateOnBlur: true,
  clearServerErrorOnChange: true,
  validate(values) {
    return {
      ...(values.name.length > 80 ? { name: "Name is too long" } : {})
    };
  },
  submit: updateProfileMutation,
  mapServerErrors(error) {
    const cause = error.cause;
    return cause instanceof ApiClientError && cause.status === 422
      ? { email: "Email is already taken" }
      : {};
  },
  autosave: {
    debounce: 800,
    validate: true,
    when: (form) => form.dirty && !form.submitting
  },
  steps: [
    { name: "profile", fields: ["name"] },
    { name: "contact", fields: ["email"] }
  ]
});

Use in React

tsx
import { useMeshForm } from "statemesh-core";

function ProfileForm() {
  const form = useMeshForm<{
    name: string;
    email: string;
    links: Array<{ url: string }>;
  }>("profile.form");

  return (
    <form onSubmit={(e) => void form.submit(e).catch(() => undefined)}>
      <input {...form.field("name")} />
      {form.touched.name && form.errors.name && <p>{form.errors.name}</p>}

      <input {...form.field("email")} />
      {form.validatingFields.email && <p>Checking email...</p>}
      {form.errors.email && <p>{form.errors.email}</p>}

      {form.fieldArray("links").items.map((link, index) => (
        <input
          key={index}
          value={link.url}
          onChange={(e) => form.fieldArray("links").update(index, { url: e.target.value })}
        />
      ))}

      {form.autosaving && <p>Saving draft...</p>}
      <button disabled={form.submitting}>
        {form.submitting ? "Saving..." : "Save"}
      </button>
    </form>
  );
}

Field Props

form.field("email") returns input props for React fields:

ts
const props = form.field("email");
// { value, onChange, onBlur, name }

Input Helpers

tsx
<input type="checkbox" {...form.checkbox("alerts")} />
<input type="radio" {...form.radio("plan", "enterprise")} />
<select {...form.select("country")} />
<input type="file" {...form.file("avatar")} />

Schema Adapters

Use Zod, Yup, or Standard Schema for validation:

ts
import { zodSchema, yupSchema, standardSchema } from "statemesh-core";

// Zod
mesh.form("profile.form", {
  schema: zodSchema(profileSchema),
  // ...
});

// Yup
mesh.form("profile.form", {
  schema: yupSchema(profileSchema),
  // ...
});

// Standard Schema (Zod 4, Valibot, ArkType, etc.)
mesh.form("profile.form", {
  schema: standardSchema(profileSchema),
  // ...
});

Field Arrays

Manage dynamic lists of fields:

ts
const links = form.fieldArray("links");

links.items;                    // Array of items
links.append({ url: "" });      // Add to end
links.insert(0, { url: "" });   // Insert at index
links.update(0, { url: "..." }); // Update at index
links.remove(0);                // Remove at index
links.move(0, 1);               // Move from index 0 to 1
links.replace([{ url: "..." }]); // Replace all items

Autosave

Configure automatic saving:

ts
{
  autosave: {
    debounce: 800,          // Debounce delay in ms
    validate: true,         // Validate before saving
    when: (form) => form.dirty && !form.submitting  // Conditional
  }
}
tsx
// Check autosave status
if (form.autosaving) return <p>Saving draft...</p>;

// Force autosave
form.autosaveNow();

Multi-Step Forms

Define steps and navigate between them:

ts
{
  steps: [
    { name: "profile", fields: ["name", "bio"] },
    { name: "contact", fields: ["email", "phone"] },
    { name: "preferences", fields: ["theme", "lang"] }
  ]
}
tsx
form.currentStep;     // "profile"
form.stepFields;      // ["name", "bio"]
form.nextStep();
form.previousStep();
form.goToStep("contact");

Server Errors

Set server errors separately from client errors:

ts
form.setServerErrors({ email: "Already taken" });

Reset to Server Data

Replace values and use the payload as the new dirty baseline:

ts
form.resetToServer(serverProfile);

Form State

FieldTypeDescription
valuesTCurrent form values
errorsFormErrors<T>Validation errors
touchedFormTouched<T>Which fields have been touched
dirtybooleanTrue when values differ from initial
dirtyFieldsRecord<string, boolean>Per-field dirty state
submittingbooleanTrue during submission
validatingbooleanTrue during async validation
validatingFieldsRecord<string, boolean>Per-field validation state
isValidbooleanTrue when no errors and not validating
autosavingbooleanTrue during autosave
currentStepstringCurrent step name
submit(event) => Promise<void>Submit the form
reset() => voidReset to initial values

Important Notes

TIP

Field-level validators can be async. StateMesh debounces them and tracks per-field validation state.

WARNING

clearServerErrorOnChange: true clears server errors when the user edits the field. This prevents stale error messages from confusing users.

TIP

Forms are registered on the mesh like actions and transactions. Use { replace: true } to override in tests or HMR.

Next Steps

Released under the MIT License.