Form Management
Overview
FormManager<T> provides reactive, type-safe form state management. It tracks field values, per-field validation errors, touched status, submission state, and overall validity without coupling to any rendering layer. A subscribe() method lets UI components or stores react to state changes.
Key Concepts
FieldConfigdescribes one field: its name, initial value, whether it is required, and an array of validation rules.FormState<T>is the complete reactive snapshot: values, errors, touched flags,isSubmitting, andisValid.- Validation runs on every
setValue()call for the changed field and on all fields whensubmit()is called. Submission is blocked when any field is invalid. validationRulesis a built-in collection of common rules (email,minLength,maxLength,pattern) you can compose without writing custom validators.
API Reference
FieldConfig<T>
interface FieldConfig<T = any> {
name: string;
initialValue: T;
validationRules?: ValidationRule<T>[];
required?: boolean;
}
ValidationRule<T>
interface ValidationRule<T = any> {
validate: (value: T) => boolean;
message: string;
}
FormState<T>
interface FormState<T extends Record<string, any>> {
values: T;
errors: Partial<Record<keyof T, string>>;
touched: Partial<Record<keyof T, boolean>>;
isSubmitting: boolean;
isValid: boolean;
}
FormManager<T>
class FormManager<T extends Record<string, any>> {
constructor(fields: FieldConfig[])
getState(): FormState<T>
setValue<K extends keyof T>(name: K, value: T[K]): void
setTouched<K extends keyof T>(name: K, touched?: boolean): void
submit(onSubmit: (values: T) => Promise<void> | void): Promise<void>
reset(): void
subscribe(listener: (state: FormState<T>) => void): () => void // returns unsubscribe
}
function createForm<T extends Record<string, any>>(fields: FieldConfig[]): FormManager<T>
Built-in validationRules
const validationRules = {
email: ValidationRule<string>
minLength: (min: number) => ValidationRule<string>
maxLength: (max: number) => ValidationRule<string>
pattern: (regex: RegExp, message: string) => ValidationRule<string>
}
Basic Usage
import { createForm, validationRules } from '@tagna/udiot';
interface LoginValues {
email: string;
password: string;
}
const form = createForm<LoginValues>([
{
name: 'email',
initialValue: '',
required: true,
validationRules: [validationRules.email],
},
{
name: 'password',
initialValue: '',
required: true,
validationRules: [validationRules.minLength(8)],
},
]);
// React to state changes (e.g., re-render a UI component)
const unsubscribe = form.subscribe((state) => {
console.log('isValid:', state.isValid, 'errors:', state.errors);
});
// Update a field value (triggers validation for that field)
form.setValue('email', 'user@example.com');
form.setTouched('email');
// Submit — validates all fields, calls handler only if all are valid
await form.submit(async (values) => {
await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(values),
headers: { 'Content-Type': 'application/json' },
});
});
unsubscribe();
Common Patterns
Custom validation rules
import { createForm, FieldConfig, ValidationRule } from '@tagna/udiot';
const strongPassword: ValidationRule<string> = {
validate: (v) => /[A-Z]/.test(v) && /[0-9]/.test(v) && v.length >= 12,
message: 'Password must be 12+ chars and contain uppercase and a digit',
};
const form = createForm<{ password: string }>([
{ name: 'password', initialValue: '', required: true, validationRules: [strongPassword] },
]);
Connecting form state to a store
import { createForm, ReactiveStore, Store, Mutation } from '@tagna/udiot';
interface ProfileState { name: string; email: string; dirty: boolean; }
@ReactiveStore({ name: 'ProfileStore' })
class ProfileStore extends Store<ProfileState> {
constructor() { super({ name: '', email: '', dirty: false }); }
@Mutation()
sync(values: Partial<ProfileState>): void {
this.dispatch({ type: 'SYNC', payload: values });
}
}
const profileStore = new ProfileStore({ name: '', email: '', dirty: false });
const form = createForm<{ name: string; email: string }>([
{ name: 'name', initialValue: '', required: true },
{ name: 'email', initialValue: '', required: true },
]);
form.subscribe((state) => {
if (state.isValid) {
profileStore.sync({ ...state.values, dirty: true });
}
});
Reset on successful submission
await form.submit(async (values) => {
await apiService.createUser(values);
form.reset(); // clear values, errors, touched flags
});
Integration Notes
FormManageris framework-agnostic — it does not import from the DI or state modules. Wrap it in an@Injectable()service when you need it injected.isSubmittingis set totrueat the start ofsubmit()and back tofalsein afinallyblock, even if theonSubmithandler throws. Always checkisSubmittingto disable submit buttons.- All fields are marked as
touchedwhensubmit()is called, so error messages appear for untouched required fields on first submit attempt. - The
subscribecallback is called synchronously on everysetValue,setTouched,submit, andresetcall.
Pitfalls
validationRules.emailis a rule object, not a factory. Use it directly:validationRules: [validationRules.email].validationRules.minLength,maxLength, andpatternare factories that return a new rule each time. Call them:validationRules.minLength(8).- Field names in
FieldConfig.namemust match the keys ofTexactly. TypeScript will catch mismatches when you usesetValue<K extends keyof T>. FormManagerdoes not debounce validation. For expensive async validators, wrapsetValuecalls externally with a debounce utility.
From the canonical UDIOT public documentation.Try it in the playground ↗