This library provides a novel approach to interact with Angular forms and signals. The current Angular version (Angular 19) provides Template Driven Forms and Reactive Forms. This library adds a third way to interact with forms. It deeply integrates signals, but it is also open to integrate other state management libraries.
This library aims to improve the developer experience how to interact with complex forms.
Using FormGroup | FormArray | FormControl
with lots of nested objects can be quite overwhelming to set up.
Also, there is no type safety in the template.
So you might access an attribute that does not even exist and get a runtime error instead of a typescript error.
- Integrate seamlessly into the Angular ecosystem
- Easy access to nested object properties
- Two-way binding for nested object properties (nested objects and arrays)
- Listen on value updates and validation state changes on nested properties
- Strictly typed (also in the template)
- Easy validation error handling
- Open for extension (integrate any state management library)
npm install --save @zarlex/ngx-accessor
If you are working with an object signal Signal<{...}>
it is not possible to update individual object properties. You can only update the whole object.
This library provides the class SignalAccessor
to get access to every property of an object signal.
The two-way binding functionality of the accessor ensures that updates of the signal are propagated through the accessor for every object property.
Each object property can be read and written through the accessor.
In opposite direction, property updates of the accessor are applied on the signal which then updates the whole signal object.
The accessor acts as a virtual tree and decorates the actual signal object with additional functionality like validation and update events.
It can be used as you would use ngModel | ngFormControl
. Instead of using ngModel | ngFormControl
use the directive ngxAceessor
.
The directive expects an instance of a class that implements the Accessor
interface.
In order to use it, the component needs to import the NgxAccessorModule
.
Next you need to create the signal. Then you create the actual accessor and provide the signal.
In the template the ngxAccessor
directive is attached to an input (or any component that implements the ControlValueAccessor
interface ).
Provide the accessor of the attribute you want to access by calling the method access
:
<input [ngxAccessor]="accessor.access(ATTRIBUTE NAME)/>
.
The ngxAccessor
provides two-way binding to the provided accessor and the accessor reads and writes the value of the signal for the given attribute.
This means, whenever the user changes the input value, the ngxAccessor
will update the value of the accessor which then will update the value of the signal for the given attribute.
This might sound more complicated than it is so let's have a look at the following examples.
Accessing a top level attribute of the signal is as easy as calling accessor.access(ATTRIBUTE NAME)
.
The access
method ensures that the attribute name you want to access exists on the object type. If you are
accessing an attribute name that does not exist you get a typescript error.
Also, it enables auto-completion in your IDE. So you see all available attributes while typing.
import { WritableSignal, signal, Component } from '@angular/core';
import { SignalAccessor, NgxAccessorModule } from '@zarlex/ngx-accessor';
interface User {
name: string;
}
@Component({
imports: [
NgxAccessorModule
],
template: `
<input [ngxAccessor]="userAccessor.access('name')">
<input [ngxAccessor]="userAccessor.access('test')"> // Typescript error becasue the attribute test does not exist on the user object
`
})
class UserForm {
protected user: WritableSignal<User> = signal(undefined);
protected userAccessor = new SignalAccessor(this.user);
}
A nested object can be accessed by simply calling access
again. It works recursively until you reach a leaf attribute.
The typing of the access method
ensures that you can only call access
again if the current accessed attribute is an object.
If you try to call access
on a primitive attribute (for instance a string) you will get a typescript error.
You can also use the dot notation to access nested objects like userAccessor.access('address.location.lat')
.
The typing of the access method also ensures that all attributes of the dot string exist otherwise you will get a typescript error.
import { WritableSignal, signal, Component } from '@angular/core';
import { SignalAccessor, NgxAccessorModule } from '@zarlex/ngx-accessor';
interface User {
address: {
location: {
lat: number;
lng: number
}
}
}
@Component({
imports: [
NgxAccessorModule
],
template: `
<input [ngxAccessor]="userAccessor.access('address').access('location').access('lat')">
<input [ngxAccessor]="userAccessor.access('address').access('location').access('lng').access('test')"> // Typescript error becasue test does not exist
<input [ngxAccessor]="userAccessor.access('address.location.lat')">
<input [ngxAccessor]="userAccessor.access('address.location.lng')">
<input [ngxAccessor]="userAccessor.access('address.location.test')"> // Typescript error becasue test does not exist
`
})
class UserForm {
protected user: WritableSignal<User> = signal(undefined);
protected userAccessor = new SignalAccessor(this.user);
}
If the accessed attribute is an array the accessor provides the method getAccessors()
to get an accessor
for each item of the array.
The returned accessor
allows to access attributes of the array item by calling access
again. Also, the access
method ensures that you
can only access attributes that exist. If you provide an attribute name that does not exist you will get a typescript error.
The getAccessors()
method is only available if the accessed attribute is an array. If it is not array it will return never
.
import { WritableSignal, signal, Component } from '@angular/core';
import { SignalAccessor, NgxAccessorModule } from '@zarlex/ngx-accessor';
interface User {
aliases: Array<{name: string}>;
}
@Component({
imports: [
NgxAccessorModule
],
template: `
@for(aliasAccessor of user.access('aliases').getAccessors(); track aliasAccessor.id){
<input [ngxAccessor]="aliasAccessor.access('name')">
<button (click)="removeAlias($index)"> - Remove Alias</button>
}
<button (click)="addAlias()"> + Add Alias</button>
`
})
class UserForm {
protected user: WritableSignal<Partial<User>> = signal({});
protected userAccessor = new SignalAccessor(this.user);
protected addAlias(): void {
this.user.update((draft: Partial<TestProperties>) => {
draft.aliases = draft.aliases || [];
draft.aliases.push({name: undefined});
return structuredClone(draft);
})
}
protected removeAlias(index: number): void {
this.user.update((draft: Partial<TestProperties>) => {
draft.aliases.splice(index, 1);
return structuredClone(draft);
})
}
}
The accessor keeps track of validation errors, async validation state and dirty state for each property.
The validation state for each property is accessible via the validation
property of the accessor. It provides the signals
isValid
, isValidating
, and errors
.
The validation state of nested properties is bubbling up all parents. So if a nested property has a validation error the
parent has a validation error as well. If a child isValdiating
all its parents will be set to isValidating
as well.
isValidating
is set to true for async property validators while they are executedisValid
is set to true if all property validators are executed and the property has no validation errorserrors
reflect all errors that were reported by the validators
Additionally, the accessor also provides the signals isDirty
and isRequired
isDirty
is set to true once a user interacts with an input.isDirty
also bubbles up so once a child property is set to dirty the parent is set to dirty as well.isRequired
is set to true if therequiredValidator
is set on the property
import { WritableSignal, signal, Component } from '@angular/core';
import { SignalAccessor, NgxAccessorModule } from '@zarlex/ngx-accessor';
interface User {
name: string;
}
@Component({
imports: [
NgxAccessorModule
],
template: `
<label [class.error]="!accessor.access('name').validation.isValid()"
[class.validating]="accessor.access('name').validation.isValidating()"
[class.required]="accessor.access('name').isRequired()"
[class.dirty]="accessor.access('name').isDirty()">
Name
<input
type="text"
[ngxAccessor]="accessor.access('name')"
[required]="true">
@if (!accessor.access('name').validation.isValidating() && !accessor.access('name').validation.isValid()) {
<ul>
@for (error of accessor.access('name').validation.errors(); track error.id) {
<li>{{ error.message }}</li>
}
</ul>
}
</label>
`
})
class UserForm {
protected user: WritableSignal<User> = signal(undefined);
protected userAccessor = new SignalAccessor(this.user);
}
Validators can be set on a property by adding a validator directive on the element.
The following are provided, but you can also implement your own directive that provides NG_VALIDATORS
or NG_ASYNC_VALIDATORS
- required
html<input [ngxAccessor]="{...}" [required]="true">
- email
<input [ngxAccessor]="{...}" [email]="true">
- min
<input [ngxAccessor]="{...}" [min]="1">
- max
<input [ngxAccessor]="{...}" [max]="100">
- minLength
<input [ngxAccessor]="{...}" [minLength]="2">
- maxLength
<input [ngxAccessor]="{...}" [maxLength]="255">
- pattern
<input [ngxAccessor]="{...}" pattern="^[a-zA-Z]*$">
import { WritableSignal, signal, Component } from '@angular/core';
import { SignalAccessor, NgxAccessorModule } from '@zarlex/ngx-accessor';
interface User {
age: number;
}
@Component({
imports: [
NgxAccessorModule
],
template: `
<input [ngxAccessor]="userAccessor.access('age')" [required]="true" min="21" max="99">
`
})
class UserForm {
protected user: WritableSignal<User> = signal(undefined);
protected userAccessor = new SignalAccessor(this.user);
}
Validators can also be configured on the accessor constructor. If the validators are provided through the constructor the validator directives on the element will be ignored. Validators can be configured through the config argument of the accessor constructor. It accepts an object where you can configure each property through a property config. The property config allows configuring validators array. It also supports async validators.
import { maxValidator, minValidator } from '@zarlex/ngx-accessor';
import { requiredValidator } from './required';
@Component({
imports: [
NgxAccessorModule
],
template: `
<input [ngxAccessor]="userAccessor.access('age')">
`
})
class UserForm {
protected user: WritableSignal<User> = signal(undefined);
protected userAccessor = new SignalAccessor(this.user, {
'age': {
validators: [
requiredValidator(),
minValidator(21),
maxValidator(99)
]
}
});
}
The config argument also allows configuring deep nested properties by using the dot notation. Each key is validated through typescript to make sure the key exists as property.
import { requiredValidator } from '@zarlex/ngx-accessor';
interface User {
address: {
location: {lat: number, lng: number}
};
}
@Component({
imports: [
NgxAccessorModule
],
template: `
<input [ngxAccessor]="userAccessor.access('address.location.lat')">
<input [ngxAccessor]="userAccessor.access('address.location.lng')">
`
})
class UserForm {
protected user: WritableSignal<User> = signal(undefined);
protected userAccessor = new SignalAccessor(this.user, {
'address.location.lat': {
validators: [
requiredValidator()
]
},
'address.location.lng': {
validators: [
requiredValidator()
]
}
});
}
Validators can also be configured for object properties inside an array. So if the array consists of objects, and you want to add a validator for a property of the object you can also use the dot notation. The array index is omitted as the validator is applicable for each array item.
import { requiredValidator } from '@zarlex/ngx-accessor';
interface User {
aliases: Array<{name: string}>;
}
@Component({
imports: [
NgxAccessorModule
],
template: `
@for(aliasAccessor of user.access('aliases').getAccessors(); track aliasAccessor.id){
<input [ngxAccessor]="aliasAccessor.access('name')">
}
`
})
class UserForm {
protected user: WritableSignal<User> = signal(undefined);
protected userAccessor = new SignalAccessor(this.user, {
'aliases.name': {
validators: [
requiredValidator()
]
}
});
}
Writing a custom validator is as simple as providing a validator config object. The config object can configure async and sync validators.
If async
is set to true isValid
expects a function that returns a promise or observable which must contain true
(valid) or false
invalid.
If the return value of isValid
is false
the function error
is called which has to return a validator error. The returned error is then set on the accessor validation instance.
If async
is set to false
the return value of isValid
must be true
or false
and not a promise/observable.
import { requiredValidator } from '@zarlex/ngx-accessor';
class AlreadyExistError extends ValidatorError {
constructor(name: string) {
super({
id: 'alreadyExists',
message: `${name} already exists`,
params: {},
});
}
}
interface User {
name: string;
}
@Component({
imports: [
NgxAccessorModule
],
template: `
<input [ngxAccessor]="userAccessor.access('name')">
`
})
class UserForm {
protected user: WritableSignal<User> = signal(undefined);
protected userAccessor = new SignalAccessor(this.user, {
'name': {
validators: [
requiredValidator(),
{
async: true,
isValid: (value: string) => timer(2000).pipe(map(() => value !== 'Test')),
error: (value: string) => new AlreadyExistError(value)
}
]
}
});
}
The accessor allows you to listen on value changes of each property as well as validation changes and dirty changes. This works also for deep nested properties of objects and arrays.
To listen on value updates you can either use the observable that is exposed via get$
or you can use the actual virtual signal through get
.
get
is signal so to access its value you would call get()
.
Changes are also bubbling up to parents. So if a child is updated the child propagates a change but also the parent.
This is also the case for arrays. So if an array item is updated the array emits a change.
import { WritableSignal, signal, Component } from '@angular/core';
import { SignalAccessor, NgxAccessorModule } from '@zarlex/ngx-accessor';
interface User {
name: string;
address: {location: {lat: number, lng: number}},
aliases: Array<{name: string}>
}
@Component({
imports: [
NgxAccessorModule
],
template: `
<input [ngxAccessor]="userAccessor.access('name')">
<input [ngxAccessor]="userAccessor.access('address.location.lng')">
@for(aliasAccessor of user.access('aliases').getAccessors(); track aliasAccessor.id){
<input [ngxAccessor]="aliasAccessor.access('name')">
<button (click)="removeAlias($index)"> - Remove Alias</button>
}
<button (click)="addAlias()"> + Add Alias</button>
`
})
class UserForm {
protected user: WritableSignal<User> = signal(undefined);
protected userAccessor = new SignalAccessor(this.user);
constructor() {
this.accessor.access('name').get$().subscribe(update => console.log('Name was updated', update));
effect(() => console.log('Name was updated', this.accessor.access('name').get()));
this.accessor.access('address').get$().subscribe(update => console.log('Address was updated', update));
effect(() => console.log('Address was updated', this.accessor.access('address').get()));
this.accessor.access('address.location.lng').get$().subscribe(update => console.log('Lng was updated', update));
effect(() => console.log('Lng was updated', this.accessor.access('address.location.lng').get()));
this.accessor.access('aliases').get$().subscribe(update => console.log('Aliases array was updated', update));
effect(() => console.log('Aliases array was updated', this.accessor.access('aliases').get()));
}
}
If a different state library is used (for instance Akita) a custom adapter can be provided which
has to implement the Accessor
interface.