Headless Core

FormController.ts

Reference for the FormController class

Overview

export class FormController<TOutput extends object>
  • TOutputrepresents the final validated shape of your form data. It defines the full object structure that the form is expected to produce after successful validation and submission.

FormController<TOutput> is the central state manager of your form — a single, type-safe source of truth that orchestrates data, fields, validation, and submission lifecycle.

It holds and mutates form data immutably (powered by immer), tracks field registration dynamically, manages dirty/touched state, and coordinates schema-based validation via StandardSchemaV1. Through its event system, it emits fine-grained updates for value changes, validation, submission status, and field lifecycle events — making it framework-agnostic and highly reactive.

With FormController, you can:

  • Maintain deeply nested, strongly-typed form data
  • Register/unregister fields dynamically
  • Track dirty, valid, validating, and submitting states
  • Run partial or full schema validation on demand
  • Generate robust submit handlers with built-in error focusing
  • React to granular form events without coupling to UI

In short, FormController is the core engine that keeps your form predictable, extensible, and fully controlled.


Fields

📘 this.data

data: DeepReadonly<DeepPartial<TOutput>>;
  • TOutput*from FormController's generics (See #Overview)

Returns an immutable version of current form data.

📘 this.initialData

initialData: DeepReadonly<DeepPartial<TOutput>>;
  • TOutput*from FormController's generics (See #Overview)

Returns an immutable version of initial form data.

📘 this.issues

issues: readonly StandardSchemaV1.Issue[];

Returns an immutable list of current issues. (See Validation page)

📘 this.path

path: FieldPathBuilder<TOutput>;
  • TOutput*from FormController's generics (See #Overview)

A very handy helper to build segmented paths off of TOutput

Examples

formController.path.of("username"); // <-- IntelliSense works!
// Or
formController.path.of((data) => data.username);
formController.path.of("address.city"); // <-- IntelliSense works!
// Or
formController.path.of((data) => data.address.city);
formController.path.of("friends[42].name"); // <-- IntelliSense works!
// Or
formController.path.of((data) => data.friends[42].name);

📘 this.isDirty

isDirty: boolean;

Returns whether there exists a registered field that is dirty.

📘 this.isValid

isValid: boolean;

Returns whether none of the registered fields have an issue.

Considered false while validating (isValidating), and only becomes true when validation completes with no issues.

📘 this.isValidating

isValidating: boolean;

Returns whether controller is currently validating or not.

📘 this.isSubmitting

isSubmitting: boolean;

Returns whether controller is currently submitting or not.

📘 this.triedSubmitting

triedSubmitting: boolean;

Returns whether controller has tried to submit before.

This value is set to true after the very first submission attempt.


Events

📘 this.events

declare const events: NanoEvents<{
  /** Emitted when `this.isSubmitting` changes */
  submissionStatusChange(isSubmitting: boolean): void;
  /** Emitted when `this.isValidating` changes */
  validationStatusChange(isValidating: boolean): void;

  /** Emitted when a field is registered */
  fieldRegistered(fieldPath: FieldPath.Segments): void;
  /** Emitted when a field is unregistered */
  fieldUnregistered(fieldPath: FieldPath.Segments): void;
  /** Emitted when a field's `isTouched` changes */
  fieldTouchUpdated(path: FieldPath.Segments): void;
  /** Emitted when a field's `isDirty` changes */
  fieldDirtyUpdated(path: FieldPath.Segments): void;
  /** Emitted when a field's `issues` changes */
  fieldIssuesUpdated(fieldPath: FieldPath.Segments): void;
  /** Emitted when a field's `reset()` is invoked */
  fieldReset(fieldPath: FieldPath.Segments): void;

  /** Emitted when a HTMLELement is bound to a field */
  elementBound(fieldPath: FieldPath.Segments, el: HTMLElement): void;
  /** Emitted when a HTMLELement is unbound from a field */
  elementUnbound(fieldPath: FieldPath.Segments): void;

  /** Emitted when validation is triggered on a field */
  fieldValidationTriggered(fieldPath: FieldPath.Segments): void;

  /** Emitted when a field's `value` is updated */
  fieldValueChanged(
    fieldPath: FieldPath.Segments,
    newValue: {} | undefined,
    oldValue: {} | undefined,
  ): void;
}>;

Reactive event bus of the FormController.

All state transitions inside the controller are emitted through this object. It allows UI adapters, bindings, devtools or external observers to react to form changes without directly coupling to internal state.

Examples

this.events is powered by NanoEvents, a minimal, dependency-free event emitter.

It provides a simple and predictable API:

const unsubscribe = formController.events.on(
  "fieldValueChanged", // <-- IntelliSense works here!
  (path, next, prev) => {
    console.log("Changed:", path, prev, "->", next);
  },
);

// ... later on

unsubscribe();

Methods

📙 Constructor

declare namespace FormController {
  export type Configs<TOutput extends object> = {
    initialData?: DeepPartial<TOutput>;
    validationSchema?: StandardSchemaV1<unknown, TOutput>;
    equalityComparators?: Map<any, (a: any, b: any) => boolean>;
  };
}
constructor(config: FormController.Configs<TOutput>)
  • TOutput*from FormController's generics (See #Overview)

Constructs a new form controller that holds data, initialData and registered fields. The form controller is the brain of the whole ecosystem.

Returns

FormController<TOutput>;
  • TOutput*from FormController's generics (See #Overview)

Constructor Args

config.validationSchema?:
StandardSchemaV1
If present, the controller will use this schema to perform validations.
config.initialData?:
DeepPartial<TOutput>
A deeply partial version of TOutput. If present, form will use these values as initial.
config.equalityComparators?:
Map<any, (a: any, b: any) => boolean>
A lookup for custom equality comparators. It is used to determine if a data value is dirty, by comparing to the corresponding initialData value.

Examples

const formController = new FormController<User>({});
// ^ TOutput can be explicitly declared
const formController = new FormController({
  validationSchema: UserSchema, // <-- TOutput can also be infered from the schema
});
const formController = new FormController({
  validationSchema: UserSchema,
  initialData: { username: "" },
});
class Address { ... }

const formController = new FormController({
  // You can impl a custom comparison strategy for classes
  equalityComparators: new Map([
    [Address, (a: Address, b: Address) => a.equals(b)]
  ])
});

📙 registerField()

declare function registerField<TPath extends FieldPath.Segments>(
  path: TPath,
  config?: {
    defaultValue?: Suppliable<FieldPath.Resolve<TOutput, TPath>>;
    overrideInitialValue?: boolean;
  },
): FormField<TOutput, FieldPath.Resolve<TOutput, TPath>>;
  • TOutput*from FormController's generics (See #Overview)
  • TPathsegmented path of the field respective to TOutput

Creates a new FormField sitting at given TPath, registers it to this controller and returns.

Returns

FormField<TOutput, FieldPath.Resolve<TOutput, TPath>>;
  • TOutput*from FormController's generics (See #Overview)
  • TPath*from registerField()s generics (See registerField())
  • FieldPath.Resolve<TOutput, TPath>value type of TOutput at given TPath

Field created and registered to the controller:

Arguments

path!
TPath extends FieldPath.Segments
Represents where this field is supposed to sit at TOutput. Used while modifying, reading and validating the value of this field.
config.defaultValue?:
Suppliable<FieldPath.Resolve<TOutput, TPath>>
If present, it will be used to set value of this.data at TPath when current value is null | undefined.
config.overrideInitialValue?:
boolean
If true, value of this.initialData at TPath will also change with defaultValue when current value is null | undefined.

Examples

const path = formController.path.of("username");
const field = formController.registerField(path);
// formController will now have "username" field registered
const path = formController.path.of("email");
const field = formController.registerField(path, {
  defaultValue: "john@doe.com",
});
console.log(field.value); // <-- "john@doe.com"
console.log(field.isDirty); // <-- true
const path = formController.path.of("email");
const field = formController.registerField(path, {
  defaultValue: "john@doe.com",
  overrideInitialValue: true,
});
console.log(field.value); // <-- "john@doe.com"
console.log(field.isDirty); // <-- false

📙 unregisterField()

declare function unregisterField(path: FieldPath.Segments): boolean;

Removes the field at the given path from the controller.

If a field exists at the provided path, it is removed from the internal registry and a fieldUnregistered event is emitted.

Returns

boolean;
  • true → A field was found and successfully unregistered
  • false → No field was registered at the given path

Arguments

path!
FieldPath.Segments
Segmented path of the field to be removed.Must match the exact path used during registerField().

Examples

const path = formController.path.of("username");
formController.unregisterField(path);
const path = formController.path.of("username");
const field = formController.registerField(path);
formController.unregisterField(field.path); // <-- true

📙 getField()

declare function getField<TPath extends FieldPath.Segments>(
  path: TPath,
): FormField<TOutput, FieldPath.Resolve<TOutput, TPath>> | undefined;
  • TPathsegmented path of the field

Returns the registered FormField at the given TPath, if it exists.

If no field has been registered at the provided path, undefined is returned.

Behavior Notes
  • getField() does not create or register a field.
  • It only retrieves an already registered field.
  • Safe to call repeatedly.
  • Fully type-safe — the returned field's value type is inferred from TPath.
If you need to ensure a field exists, call registerField() first.

Returns

FormField<TOutput, FieldPath.Resolve<TOutput, TPath>> | undefined;
  • TOutput*from FormController's generics (See #Overview)
  • TPath*from getField()'s generics (See getField())
  • FieldPath.Resolve<TOutput, TPath>value type of TOutput at given TPath

Arguments

path!
FieldPath.Segments
Segmented path pointing to a field inside TOutput. Must match the exact path used during registerField().

Examples

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

const field = formController.getField(path);

console.log(field); // undefined (if not registered yet)
interface Model {
  email: string;
}

const formController = new FormController<Model>({});

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

formController.registerField(path);

const field = formController.getField(path);
//    ^? FormField<Model, string> | undefined

console.log(field?.value);
//                 ^? string | undefined

📙 reset()

declare function reset(newInitialData?: DeepPartial<TOutput>): void;
  • TOutput*from FormController's generics (See #Overview)

Resets the form back to its initial state.

All registered fields are reset, validation issues are cleared, submission flags are restored, and data is reverted to initialData.

If newInitialData is provided, it becomes the new source of truth for both initialData and data.

Arguments

newInitialData?:
DeepPartial<TOutput>
If provided, replaces the current initialData and reinitializes data from it.

Examples

// Reset to original initialData
formController.reset();
// Replace initialData entirely
formController.reset({
  username: "john",
  email: "john@doe.com",
});
formController.data; // { username:"john", email: "john@doe.com" }
formController.initialData; // { username:"john", email: "john@doe.com" }
// After reset
console.log(formController.isTouched); // false
console.log(formController.isDirty); // false
console.log(formController.issues); // []
console.log(formController.triedSubmitting); // false

📙 validateForm()

declare function validateForm(): Promise<void>;

Runs full-form validation using the configured validationSchema.

All registered fields are validated against the current data. Validation issues are reconciled, updated, and corresponding events are emitted.

Behavior
  • Skips execution if:
    • Validation is already in progress (isValidating === true)
    • No validationSchema is configured
  • Sets isValidatingtrue during execution
  • Validates the entire data object
  • Reconciles validation issues per registered field
  • Emits:
    • validationStatusChange
    • fieldValidationTriggered (for each registered field)
    • fieldIssuesUpdated (when issues change)
  • Sets isValidatingfalse after completion
Validation issues not tied to registered fields are still stored in this.issues.

Returns

Promise<void>;

Resolves when validation completes.

Examples

await formController.validateForm();

if (formController.isValid) {
  console.log("Form is valid!");
} else {
  console.log("Issues:", formController.issues);
}
// Trigger validation without submitting
button.innerHTML = "Validate";
button.onclick = async () => {
  await formController.validateForm();
};

📙 validateField()

declare function validateField<TPath extends FieldPath.Segments>(
  path: TPath,
): Promise<void>;
  • TPathsegmented path of the field to validate

Runs validation for a specific field path.

The entire data object is validated using the configured validationSchema, but only issues related to the given TPath (and its descendants) are reconciled and updated.

Behavior
  • Skips execution if:
    • Validation is already in progress (isValidating === true)
    • No validationSchema is configured
  • Sets isValidatingtrue during execution
  • Ensures the field is registered (auto-registers if missing)
  • Validates the entire data object
  • Reconciles issues affecting:
    • The exact path
    • Any descendant paths
  • Emits:
    • validationStatusChange
    • fieldValidationTriggered (for the given path)
    • fieldIssuesUpdated (if issues changed)
  • Sets isValidatingfalse after completion

Returns

Promise<void>;

Resolves when validation completes.

Arguments

path
TPath extends FieldPath.Segments
Segmented path of the field to validate.

Examples

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

const field = formController.getField(path);

await formController.validateField(path);

if (field.isValid) {
  console.log("Field is valid!");
} else {
  console.log("Issues:", field.issues);
}
// Trigger validation without submitting
button.innerHTML = "Validate";
button.onclick = async () => {
  const path = formController.path.of("email");
  await formController.validateField(path);
};

📙 createSubmitHandler()

declare namespace FormController {
  export interface PreventableEvent {
    preventDefault(): void;
  }

  export type SubmitSuccessHandler<
    TOutput extends object,
    TEvent extends PreventableEvent | null | undefined,
  > = (data: DeepReadonly<TOutput>, event: TEvent) => void | Promise<void>;

  export type SubmitErrorHandler<
    TEvent extends PreventableEvent | null | undefined,
  > = (issues: StandardSchemaV1.Issue[], event: TEvent) => void | Promise<void>;
}
declare function createSubmitHandler<
  TEvent extends FormController.PreventableEvent | null | undefined,
>(
  onSuccess?: FormController.SubmitSuccessHandler<TOutput, TEvent>,
  onError?: FormController.SubmitErrorHandler<TEvent>,
): (event: TEvent) => Promise<void>;
  • TOutput*from FormController's generics (See #Overview)
  • TEventEvent type passed from the consumer (e.g. SubmitEvent)

Creates a fully managed submit handler for the form.

The returned function handles:

  • event.preventDefault() (if event exists)
  • Full-form validation
  • Submission state tracking
  • Automatic focusing of the first invalid field
  • Calling onSuccess or onError based on validation

Returns

(event: TEvent) => Promise<void>;

Arguments

onSuccess?:
FormController.SubmitSuccessHandler<TOutput, TEvent>
Called when validation passes and there are no issues. Receives fully-typed form data TOutput, and the original event.
onError?:
FormController.SubmitErrorHandler<TEvent>
Called when validation fails. Receives all validation issues, and the original event.

Examples

interface Model {
  username: string;
  password: string;
}

const formController = new FormController<Model>();
const formEl = document.getElementById("my-form") as HTMLFormElement;

formEl.onsubmit = formController.createSubmitHandler(
  async (data, event) => {
    //         ^? SubmitEvent, inferred automatically
    await api.loginUser(data);
    //                  ^? Model
  },
  async (issues) => {
    console.error("Validation failed:", issues);
    //                                  ^? StandardSchemaV1.Issue[]
  },
);
// With explicit event typing
const handleSubmit = formController.createSubmitHandler<SubmitEvent>(
  async (data, event) => {
    console.log("Submitting:", data);
  },
);
Copyright © 2026