Skip to content

Novel approach to connect Angular signals with forms. It is 100% compatible with ngModel | formControl and provides type safety in the template

Notifications You must be signed in to change notification settings

Zarlex/ngxAccessor

Repository files navigation

NgxAccessor

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.

Motivation

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.

Goals

  • 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)

Play with it

Open in StackBlitz

How to install it

npm install --save @zarlex/ngx-accessor

How it works

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.

How to use it

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.

Access top level attribute

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);
}

Access nested object

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);
}

Access array

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);
    })
  }
}

Validation

The accessor keeps track of validation errors, async validation state and dirty state for each property.

Reading validation, dirty and required state

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 executed
  • isValid is set to true if all property validators are executed and the property has no validation errors
  • errors 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 the requiredValidator 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);
}

Validator directives

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);
}

Validation config on accessor

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)
      ]
    }
  });
}

Validation config on accessor for nested objects

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()
      ]
    }
  });
}

Validation config on accessor for arrays

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()
      ]
    }
  });
}

Custom validators

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)
        }
      ]
    }
  });
}

Listen on updates

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.

Value updates

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()));
  }
}

Implement your own Accessor

If a different state library is used (for instance Akita) a custom adapter can be provided which has to implement the Accessor interface.

About

Novel approach to connect Angular signals with forms. It is 100% compatible with ngModel | formControl and provides type safety in the template

Topics

Resources

Stars

Watchers

Forks