Headless Core

Recipes

This cookbook covers common form workflows.

📚 Basic Form with Schema Validation

Goal: Validate a simple login form and submit safely.

type LoginForm = {
  email: string;
  password: string;
};

const formController = new FormController<LoginForm>({
  initialData: {
    email: "",
    password: "",
  },
  validationSchema: loginSchema, // StandardSchemaV1
});

const emailField = formController.registerField(form.path.of("email"));

const passwordField = formController.registerField(form.path.of("password"));

const handleSubmit = formController.createSubmitHandler(async (data) => {
  await api.login(data);
});

formEl.onsubmit = handleSubmit;

📚 Default Values for Dynamically Mounted Fields

Goal: Provide fallback values when a field appears conditionally.

const field = formController.registerField(
  formController.path.of("profile.bio"),
  { defaultValue: "Hello there 👋" },
);

If data.profile.bio is null | undefined, it gets initialized.

To also modify initialData:

formController.registerField(formController.path.of("profile.bio"), {
  defaultValue: "Hello there 👋",
  overrideInitialValue: true,
});

📚 On-Blur Field Validation

Goal: Validate only the field the user just left.

const path = formController.path.of("email");

input.onblur = async () => {
  await formController.validateField(path);
};

📚 Reset With New Server Data

Goal: Rehydrate form after fetching user data.

const user = await api.getUser();

formController.reset(user);

This:

  • Replaces initialData
  • Recreates data
  • Clears issues
  • Resets dirty/touched state
  • Keeps fields registered

Perfect for edit forms.

📚 Conditional Fields

Goal: Dynamically add/remove fields.

const addressPath = formController.path.of("address", "street");

if (showAddress) {
  formController.registerField(addressPath);
} else {
  formController.unregisterField(addressPath);
}

Data remains intact — only the field abstraction changes.

📚 Disable Submit Button Reactively

formController.events.on("submissionStatusChange", (isSubmitting) => {
  submitButton.disabled = isSubmitting;
});

Or reflect validation:

formController.events.on("validationStatusChange", (isValidating) => {
  spinner.visible = isValidating;
});

📚 Force Dirty on Change

formController.events.on("fieldValueChanged", (path, newValue, oldValue) => {
  const field = formController.getField(path);

  if (field != null) {
    // Normally dirtiness is determined by comparing current value to initial value.
    // This forces it to be dirty regardless of that comparison.
    field.markDirty();
  }
});
Copyright © 2026