Heavily inspired by Angular Forms, this package provides utilities for complex Model-driven form management in Lit-based Web Components.
đźš§ This library is published in order to get feedback, it's not production ready, and it's not yet published to npm. đźš§
- Single source of truth (Model-driven)
- Fully type-safe, no nullable values unless explicitly defined
- 3 composable Controllers:
FormControl,FormGroup,FormArray - Template bindings
- State tracking (dirty, touched, blurred)
- UI State helpers (disabled, readonly)
- Imperative manipulation (set, patch, setDirty, setTouched...)
- Dynamic forms (FormArray) with convenience methods: move, swap, insertAt, removeAt, append...
- Support for binding multiple elements to the same control
- Built-in validators which automatically add attributes to the bound elements (optional)
- Utility for creating custom validators with side-effects (eg. change the default a11y attributes)
- Asynchronous Validators
- Validation status (
VALID,INVALID,PENDING) - Cross-field validation (FormGroup, FormArray)
- Imperatively add/remove Validators
- Imperatively re-run validators
- RxJS Observables
- Support for custom controls
- Create your own
ControlAccessors for custom-elements, in order to:- Manipulate its value
- React to custom events
- React to a
ValidationStatechange (eg. set custom attributes) - React to a
UIStatechange (eg. setdisabledorreadonlyattributes)
- Create your own
This package provides 3 main classes which you can use to compose your forms:
FormControl, represents a single controlFormGroup, represents a group of controlsFormArray, represents an array of controls
Nested controls are allowed, so you can represent any kind of hierarchy.
A convenience class is also exported to make it easier to compose forms, called FormBuilder. It can reduce the boilerplate and apply the same configuration to all the FormControls it generates.
The form's model is fully typed and expects default values on declaration, which will be used upon calling reset().
Validators are also provided to validate your FormControls:
requiredrequiredTrueminLengthmaxLengthminmaxemailpattern
You're free to write and use your owns as simple functions.
This library also lets you specify Asynchronous Validators (which must return a Promise), and both Synchronous and Asynchronous Validators for FormGroups and FormArrays (Cross-field Validation).
Given the hierarchical nature of FormGroups, JavaScript is expected to handle form submissions. No progressive enchancement feature is planned, because nested objects would not be sent in a regular form submission.
RxJS is a required peerDependency, as the library provides some Observables to observe field changes and uses Observables intensively.
Represents a single control.
// Standard syntax (only `defaultValue` is mandatory)
name = new FormControl(this, {
defaultValue: 'John', // The control is inferred as FormControl<string>
validators: [],
asyncValidators: [],
...
})
// With FormBuilder
fb = new FormBuilder(this);
name = this.fb.control('John');
// Binding
render() {
return html`
<input type="text" ${this.name.bind()}>
`
}config: FormControlConfig: the configuration for the control. It contains:validators: Validator[], an array of validatorsasyncValidators: AsyncValidator[], an array of asynchronous validatorsupdateOn: 'input' | 'blur', strategy for when the model should be updatedaccessorFactory: ControlAccessorFactory, a factory function which accepts anHTMLElementand returns aControlAccessor
bind: a Lit Directive to bind the control to an HTMLElementvalue: T: the current valuestatus: ValidationStatus: eitherVALID,INVALIDorPENDINGerrors: string[]: current errorshasError(error: string): boolean: if the control has a particular errorreset(clearStates = true): void: sets to the default value,clearStatessets dirty/touched/blurred tofalseset(value: T): void: sets a new valueisDirty: boolean: if the value has ever been changed by the userisTouched: boolean: if the field has been touched by the userisBlurred: boolean: if the field has been blurred by the useruiState: UIState: eitherENABLED,DISABLEDorREADONLYsetDirty(is = true): voidsetTouched(is = true): voidsetBlurred(is = true): voidsetUIState(state: UIState): voidsetFixedErrors(errors: ValidationError[]): void: use this to set custom errors, they won't be erased by validatorssetValidators(validators: Validator[]): void: replaces the validatorssetAsyncValidators(asyncValidators: AsyncValidator[]): void: replaces the async validatorsrerunValidatorsrerunAsyncValidatorsvalueChanges(): Observable<T>uiStateChanges(): Observable<UIState>statusChanges(): Observable<ValidationStatus>
Represents a group of controls.
fb = new FormBuilder(this);
form = this.fb.group({
user: this.fb.group({
name: this.fb.control(''),
surname: this.fb.control(''),
}),
consent: this.fb.control(false)
}, {
validators: [],
asyncValidators: [],
});
// Binding (dotted syntax for nested FormGroups)
render() {
const { bind } = this.form;
return html`
<input type="text" ${bind('user.name')}>
<input type="text" ${bind('user.surname')}>
<input type="text" ${bind('consent')}>
`
}controls: T: the structure you providedconfig: FormGroupConfig: the configuration for the group. It contains:validators: Validator[], an array of validatorsasyncValidators: AsyncValidator[], an array of asynchronous validators
bind: (key: BindKey<T>, config: BindConfig) => Directive: a Lit Directive to bind the controls to HTMLElementsbindWith: (config) => (key) => Directive: a curried version of bind with the argument in reverse order, useful for reusing the same configuration for every fieldvalue: GroupValue<T>: the current value of the entire formenabledValue: EnabledGroupValue<T>: the current value of the entire form, without disabled fieldsstatus: ValidationStatus: eitherVALID,INVALIDorPENDING. It combines child validators with the group's validators. Invalid or pending if one child is invalid or pending.errors: string[]: current cross-field errorshasError(error: string): boolean: if the form has a particular cross-field errorget(key: K): T[K]: retrieves a controlreset(clearStates = true): void: sets to the default value,clearStatessets dirty/touched/blurred tofalsefor each controlset(value: GroupValue<T>): void: sets a new value, use this method if you want to be sure to set every fieldpatch(value: Partial<GroupValue<T>>): void: sets a new value (partial)isDirty: boolean: if at least one child is dirtyisTouched: boolean: if at least one child is touchedisBlurred: boolean: if at least one child is blurredsetFixedErrors(errors: ValidationError[]): void: use this to set custom errors, they won't be touched by validatorssetValidators(validators: Validator[]): void: replaces the validatorssetAsyncValidators(asyncValidators: AsyncValidator[]): void: replaces the async validatorsrerunValidatorsrerunAsyncValidatorsvalueChanges(): Observable<GroupValue<T>>statusChanges(): Observable<ValidationStatus>addControl(name: string, control: AbstractControl): adds a control to the group [experimental]setControl(name: string, control: AbstractControl): replaces a control of the group [experimental]removeControl(name: string): removes a control of the group [experimental]
Represents an array of controls. They can be any of the 3 classes (FormControl, FormGroup or FormArray).
fb = new FormBuilder(this);
// The first argument is the initial controls, there's no "default" controls with FormArray's.
phones = this.fb.array<FormControl<string>>([]),
// Binding
render() {
return html`
${this.phones.controls.map(c => html`
<input type="text" ${c.bind()}>
`)}
`
}controls: T[]: the controls at each momentconfig: the configuration for the array. It contains:initialItems: T[]: initial items to be added to the arrayvalidators: Validator[], an array of validatorsasyncValidators: AsyncValidator[], an array of asynchronous validators
bind: a Lit Directive to bind the controls to HTMLElementsvalue: ArrayValue<T>[]: the current value of the arraystatus: ValidationStatus: eitherVALID,INVALIDorPENDING. It combines child validators with the array's validators. Invalid or pending if one child is invalid or pending.errors: string[]: current cross-field errorshasError(error: string): boolean: if the form has a particular cross-field errorget(index: number): T | null: retrieves a controlreset(clearStates = true): void: resets each child (does not reset the array)set(value: ArrayValue<T>[]): void: sets a new value for the array, if compatible. Does NOT create new controlsclear(): void: removes all controlsisDirty: boolean: if at least one child is dirtyisTouched: boolean: if at least one child is touchedisBlurred: boolean: if at least one child is blurredsetFixedErrors(errors: ValidationError[]): void: use this to set custom errors, they won't be touched by validatorssetValidators(validators: Validator[]): void: replaces the validatorssetAsyncValidators(asyncValidators: AsyncValidator[]): void: replaces the async validatorsrerunValidatorsrerunAsyncValidatorsvalueChanges(): Observable<ArrayValue<T>[]>valueChanges(index: number): Observable<ArrayValue<T> | null>statusChanges(): Observable<ValidationStatus>insertAt(control: T, index: number): voidappend(control: T): voidprepend(control: T): voidremoveAt(index: number): voidpop(): voidswap(indexA: number, indexB: number): void: swaps only if both indexes are validmove(from: number, to: number): void: moves only if both indexes are valid
The value property of a FormControl is not nullable by default, even if the field gets disabled you'll be able to retrieve its value. Same goes with FormGroups, its value always respects its shape.
But if you need a way to strip disabled fields from a FormGroup, you can use the enabledValue property which makes all FormControls optional. However, FormGroups and FormArrays will always be there in the value: they cannot be disabled per-se, it doesn't make sense. So, in case of nested forms, you'll have groups and arrays' properties in your final object.
The library also supports the readonly state: a FormControl can either be ENABLED, DISABLED or READONLY (one at a time). Controls which are marked as readonly will always be there even in the enabledValue. This attribute may be useful for accessibility, but watch out: not all native controls support it! But if you want to use it in certain cases, you could write your own FieldAccessor to set the underlying control as disabled even though it's in a READONLY state.
The library exports a set of ValidatorsWithEffects which resemble the native ones (required, minLength, pattern...). They'll automatically set a11y attributes on your bound elements. If you don't want this behavior, use PureValidators, which have no side-effects on the DOM.
You're free to not use the library's validators and use other libraries for that (eg. Yup). The library provides an utility method for all controls, called setFixedErrors, which lets you append custom errors to your controls and won't be erased unless you call the function again with new errors. Think of it as a "cauldron" for errors, it may be useful.
You can write your own validators if you're not satisfied with the built-in effects: for example, you may want to support Custom Elements which require maxLength (camelCase) instead of maxlength. You can reuse the same built-in logic and add your own effects like this:
import { addEffectsToValidator, PureValidators } from 'lit-reactive-forms';
// Simple validator
const requiredTrue = addEffectsToValidator(PureValidators.requiredTrue,
// This function will be called when the validator is connected...
(el) => { el.setAttribute('whatever', '') },
// ...and this one when it's disconnected
(el) => { el.removeAttribute('whatever') }
);
// Validator factory
function maxLength(n: number) {
return addEffectsToValidator(PureValidators.maxLength(n),
(el) => { el.setAttribute('maxLength', '' + n) },
(el) => { el.removeAttribute('maxLength') }
);
}If you come from Angular (which is the main inspiration for this project), you'll know that validators behave in an interesting way: they don't run if the field is already invalidated by synchronous validators. Same goes for cross-field validation: if a child is invalid, they don't run. Also, disabled fields are not validated.
Although this is a cool feature and can potentially save resources, many developers always want to know all the errors for a field, and therefore all validators must run. It can get frustrating pretty easily, forcing you to wrap your controls in nested groups just because otherwise validators wouldn't run.
This library always runs asynchronous validators for a field when its value changes and it doesn't care if its disabled or not. Some may use the disabled state just to stop interaction, but may want to validate the control anyway.
If you want to, you can debounce your validators yourself with a helper, knowing that the library will stop the API call and abort the Promise should the value change in the meantime. This way, the API call will be made either way but at least you won't make too many calls while the user is typing. Another option would be to not use asynchronous validators but listen to the form by yourself via the provided Observables (valueChanges, statusChanges). This way, you can fine-tune your calls and use setFixedErrors to set your errors manually.
Beware that synchronous validators always have precedence: this means that if a field is "synchronously" invalid, its asynchronous validators will run, but its state will be INVALID, not PENDING in the meantime.
The bind directive lets you bind to nested controls this way:
bind('user.name')But if you're dealing with a FormArray, you should map its controls yourself and bind each one individually.
Either way, if you need to get a control, FormGroup and FormArray both have a get method, which takes a property for the former or an index for the latter.
You can access nested controls this way:
form.get('user').get('name');A FormControl must have a default value, which will be used when calling reset. This way, there are no nullable values by default. The default value is also used initially.
A FormGroup doesn't really have a "value", it has controls. Its shape is fixed and cannot change: calling reset on it will cause the calling of reset on every child, nothing strange.
A FormArray works a bit differently. Since it doesn't work with values but with other controls, there's no "default value" for it, in order not to cause problems with cloning. Calling reset will not empty the array, but it will call reset on every child. If you wish to empty the array, use clear.
However, a FormArray can have an initial value: an array of controls. Beware that these are not default values, as calling reset doesn't care about them being there or not: it doesn't care.
This library is fundamentally different from how native forms work: for example, with native forms it's not possible to send nested objects. Also, disabled fields are a controversial topic: some developers use disabled to interrupt interaction, but they want the value anyway, but this is not how native form submissions work. And in case of nested controls: should the property be there or not? That's an opinion.
This library is opinionated and meant to work with JavaScript enabled in order for you to submit your values via API call. For this reason, it makes no attempt to be "progressively enhanced" in any way (as, for example, Remix does).
Different controls yield different values: for example, an <input type="text"> works with strings, <input type="number"> works with numbers.
This library detects what kind of element is bound with the bind directive and sets up an appropriate ControlAccessor, which provides methods to interact with the element.
There are different Accessors, you'll probably never touch them: TextAccessor, NumberAccessor, SelectMultipleAccessor...
If the library encounters a Custom Element, it cannot know how to communicate with it. By default, it tries with the BaseControlAccessor which treats it like an <input>.
You may want to write your own ControlAccessors for your Custom Elements: it's pretty easy! They're just classes.
This is the interface they have to implement:
interface ControlAccessor<T = any> {
getValue(): T;
setValue(value: T): void;
setUIState?(state: UIState): void;
setValidity?(status: ValidationStatus | null): void;
registerOnChange(fn: () => void): void;
registerOnTouch?(fn: () => void): void;
registerOnBlur?(fn: () => void): void;
onDisconnect?(): void;
}Instead of writing your accessor from zero, it's convenient to extend the BaseControlAccessor which implements all methods and already has a constructor setup correctly (must accept an element instance) and properties for saving the 3 callbacks for the registerOn methods.
Suppose we have a Counter element (<my-counter>), which deals with a number. This is what we could do:
export class CounterAccessor extends BaseControlAccessor<Counter, number> {
// Here we tell the library how to retrieve its value (DOM -> Model).
getValue() {
return this.el.value;
}
// Here we tell the library how to set its value (Model -> DOM).
setValue(x: number) {
this.el.value = x;
}
// This gets called whenever the UIState changes.
setUIState(uiState: UIState) {
this.el.disabled = uiState === 'DISABLED' || uiState === 'READONLY';
}
// The element may emit a custom event which is not called `input`: here we setup event listeners.
// We must notify the library when the value changes. The value isn't needed: it'll take it from `getValue`.
// We save the callback function to remove the listener later.
registerOnChange(fn: () => void) {
this.onChange = fn;
this.el.addEventListener('counterChange', this.onChange);
}
// Same as above, but for the `isTouched` property. We could also use the standard `focus` event.
registerOnTouch(fn: () => void) {
this.onTouch = fn;
this.el.addEventListener('counterFocus', this.onTouch);
}
// Same as above, but for the `isBlurred` property.
registerOnBlur(fn: () => void) {
this.onBlur = fn;
this.el.addEventListener('counterBlur', this.onBlur);
}
// Here we remove all the listeners.
onDisconnect() {
this.el.removeEventListener('counterChange', this.onChange);
this.el.removeEventListener('counterFocus', this.onTouch);
this.el.removeEventListener('counterBlur', this.onBlur);
}
}Once you have this, you can pass it to the bind directive when binding the element:
html`
<my-counter ${this.counter.bind({accessor: CounterAccessor})}></my-counter>
`However passing it every time can cause a lot of noise: more on this in the next section.
You can use FormBuilder to remove a lot of boilerplate. For example, you can set a custom configuration which will be used by all controls:
// Every control will update the model on blur
fb = new FormBuilder(this, {
updateOn: 'blur'
});But you can always override this "group" configuration with the bind directive:
html`
<!-- This field will update the model on input -->
<input ${bind('name', { updateOn: 'input' })}>
`Or, if you're using custom elements which require ControlAccessors, you can replace the accessorFactory, the function which chooses the correct Accessor for each element:
fb = new FormBuilder(this, {
accessorFactory: myAccessorFactory
});This way you don't have to specify the accessor every time with the bind directive.
This is what the default ControlAccessorFactory looks like:
export const getControlAccessor: ControlAccessorFactory = (el) => {
if (el.localName === 'input' && el.getAttribute('type') === 'checkbox') {
return new CheckboxAccessor(el as HTMLInputElement);
}
if (el.localName === 'input' && el.getAttribute('type') === 'number') {
return new NumberAccessor(el as HTMLInputElement);
}
...
return new BaseControlAccessor(el);
}You may want to reuse it, like this:
export const myAccessorFactory: ControlAccessorFactory = (el) => {
if (el.localName === 'my-counter') {
return new CounterAccessor(el as Counter);
}
return getControlAccessor(el);
}