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
ControlAccessor
s for custom-elements, in order to:- Manipulate its value
- React to custom events
- React to a
ValidationState
change (eg. set custom attributes) - React to a
UIState
change (eg. setdisabled
orreadonly
attributes)
- 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 FormControl
s 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 FormControl
s:
required
requiredTrue
minLength
maxLength
min
max
email
pattern
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 FormGroup
s and FormArray
s (Cross-field Validation).
Given the hierarchical nature of FormGroup
s, 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 anHTMLElement
and returns aControlAccessor
bind
: a Lit Directive to bind the control to an HTMLElementvalue: T
: the current valuestatus: ValidationStatus
: eitherVALID
,INVALID
orPENDING
errors: string[]
: current errorshasError(error: string): boolean
: if the control has a particular errorreset(clearStates = true): void
: sets to the default value,clearStates
sets dirty/touched/blurred tofalse
set(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
,DISABLED
orREADONLY
setDirty(is = true): void
setTouched(is = true): void
setBlurred(is = true): void
setUIState(state: UIState): void
setFixedErrors(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 validatorsrerunValidators
rerunAsyncValidators
valueChanges(): 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
,INVALID
orPENDING
. 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,clearStates
sets dirty/touched/blurred tofalse
for 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 validatorsrerunValidators
rerunAsyncValidators
valueChanges(): 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
,INVALID
orPENDING
. 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 validatorsrerunValidators
rerunAsyncValidators
valueChanges(): Observable<ArrayValue<T>[]>
valueChanges(index: number): Observable<ArrayValue<T> | null>
statusChanges(): Observable<ValidationStatus>
insertAt(control: T, index: number): void
append(control: T): void
prepend(control: T): void
removeAt(index: number): void
pop(): void
swap(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 FormGroup
s, 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 FormControl
s optional. However, FormGroup
s and FormArray
s 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 ControlAccessor
s 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 ControlAccessor
s, 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);
}