FormController.ts
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, andsubmittingstates - 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
TOutput. If present, form will use these values as initial.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
TOutputat givenTPath
Field created and registered to the controller:
Arguments
TOutput.
Used while modifying, reading and validating the value of this field.this.data at TPath when current value is null | undefined.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 unregisteredfalse→ No field was registered at the given path
Arguments
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.
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.
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
TOutputat givenTPath
Arguments
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
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.
- Skips execution if:
- Validation is already in progress (
isValidating === true) - No
validationSchemais configured
- Validation is already in progress (
- Sets
isValidating→trueduring execution - Validates the entire
dataobject - Reconciles validation issues per registered field
- Emits:
validationStatusChangefieldValidationTriggered(for each registered field)fieldIssuesUpdated(when issues change)
- Sets
isValidating→falseafter completion
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.
- Skips execution if:
- Validation is already in progress (
isValidating === true) - No
validationSchemais configured
- Validation is already in progress (
- Sets
isValidating→trueduring execution - Ensures the field is registered (auto-registers if missing)
- Validates the entire
dataobject - Reconciles issues affecting:
- The exact
path - Any descendant paths
- The exact
- Emits:
validationStatusChangefieldValidationTriggered(for the given path)fieldIssuesUpdated(if issues changed)
- Sets
isValidating→falseafter completion
Returns
Promise<void>;
Resolves when validation completes.
Arguments
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
onSuccessoronErrorbased on validation
Returns
(event: TEvent) => Promise<void>;
- TEvent*from
createSubmitHandler()'s generics (See createSubmitHandler())
Arguments
TOutput, and the original event.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);
},
);