Model Inputs - Signal API that is missing in 2-way binding

railsstudent

Connie Leung

Posted on February 19, 2024

Model Inputs - Signal API that is missing in 2-way binding

Introduction

In this blog post, I would like to show a new feature in Angular 17.2 that calls model inputs. Model input is a Signal API for 2-way binding between parent and child components. 2-way binding is always available and the official Angular documentation provides the following sizer example:

<app-sizer [(size)]="fontSizePx"></app-sizer>

export class SizerComponent {
  @Input() size!: number | string;
  @Output() sizeChange = new EventEmitter<number>();

  dec() {
    this.resize(-1);
  }
  inc() {
    this.resize(+1);
  }

  resize(delta: number) {
    this.size = Math.min(40, Math.max(8, +this.size + delta));
    this.sizeChange.emit(this.size);
  }
}
Enter fullscreen mode Exit fullscreen mode

With model inputs, I can bind the signals of the parent component to the model inputs of the child component using model() function. Therefore, the child component does not need the input and output decorators to receive input and to emit changes.

In my demo, I recreated the generic image placeholder site (https://dev.me/products/image-placeholder) twice.

Solution 1: This is the old way when Angular does not have Signal. The implementation uses primarily BehaviorSubject, RxJS operators, @Input, and @Output to build the placeholder URL.

Solution 2: The implementation uses model inputs, signal double binding, and computed signal to derive the placeholder URL.

The old way: BehaviorSubject and RxJS operators

// image-placeholder-subject.componen.ts

@Component({
  selector: 'app-image-placeholder-subject',
  standalone: true,
  imports: [FormsModule],
  template: `
    <h3>Redo https://dev.me/products/image-placeholder - with BehaviorSubject</h3>
    <div class="container">
      <div class="field">
        <label for="text">
          <span>Text: </span>
          <input id="text" name="text" [ngModel]="text" 
            (ngModelChange)="textChange.emit($event)"
          />
        </label>
      </div>
      <div class="field">
        <label for="width">
          <span>Width: </span>
          <input id="width" name="width" [ngModel]="width" 
            (ngModelChange)="widthChange.emit($event)" 
            type="number" min="10" />
        </label>
      </div>
      <div class="field">
        <label for="height">
          <span>Height: </span>
          <input id="height" name="height" [ngModel]="height" (ngModelChange)="heightChange.emit($event)" type="number" min="10" />
        </label>
      </div>
      <div class="field">
        <label for="color">
          <span>Color: </span>
          <input id="color" name="color" [ngModel]="color" (ngModelChange)="colorChange.emit($event)" />
        </label>
      </div>
      <div class="field">
        <label for="backgroundColor">
          <span>Background color: </span>
          <input id="backgroundColor" name="backgroundColor" [ngModel]="backgroundColor" (ngModelChange)="backgroundColorChange.emit($event)" />
        </label>
      </div>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ImagePlaceholderSubjectComponent {  
  @Input()
  text!: string;

  @Output()
  textChange = new EventEmitter<string>();

  @Input()
  width!: number;

  @Output()
  widthChange = new EventEmitter<number>();

  @Input()
  height!: number;

  @Output()
  heightChange = new EventEmitter<number>();

  @Input()
  color!: string;

  @Output()
  colorChange = new EventEmitter<string>();

  @Input()
  backgroundColor!: string;

  @Output()
  backgroundColorChange = new EventEmitter<string>();
}
Enter fullscreen mode Exit fullscreen mode

ImagePlaceholderSubjectComponent has a template-driven form that allows users to input text, width, height, text color and background color. Each form field has a ngModel that accepts an input and a ngModelChange event that emits changes back to the parent component.

<div class="field">
        <label for="text">
          <span>Text: </span>
          <input id="text" name="text" [ngModel]="text" 
            (ngModelChange)="textChange.emit($event)"
          />
        </label>
</div>

@Input()
text!: string;

@Output()
textChange = new EventEmitter<string>();
Enter fullscreen mode Exit fullscreen mode

[ngModel]="text" receives values from text input decorator while (ngModelChange)="textChange.emit($event)" emits the value to textChange event emitter.

Then, ImagePlaceholderSubjectComponent can bind text to a string primitive or a BehaviorSubject in the parent component.

<app-image-placeholder-subject  [text]="behaviorSubject.getValue()" (textChange)="behaviorSubject.next($event)" />
Enter fullscreen mode Exit fullscreen mode

Create a parent component for 2-way binding

// image-placeholder-subject-container.component.ts

@Component({
  selector: 'app-image-plceholder-subject-container',
  standalone: true,
  imports: [ImagePlaceholderSubjectComponent, AsyncPipe],
  template: `
    <h2>Old way - with BehaviorSubject and RxJS</h2>
    <app-image-placeholder-subject 
      [text]="textSub.getValue()"
      (textChange)="textSub.next($event)"
      [width]="widthSub.getValue()" (widthChange)="widthSub.next($event)"
      [height]="heightSub.getValue()" (heightChange)="heightSub.next($event)"
      [color]="colorSub.getValue()" (colorChange)="colorSub.next($event)"
      [backgroundColor]="backgroundColorSub.getValue()"
      (backgroundColorChange)="backgroundColorSub.next($event)"  
    />
    <img [src]="placeholderUrl$| async" alt="generic placeholder" />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ImagePlaceholderSubjectContainerComponent {
  // The way without mode inputs.  I use BehaviorSubject to implement the same behavior
  textSub = new BehaviorSubject('BehaviorSubject');
  widthSub = new BehaviorSubject(250);
  heightSub = new BehaviorSubject(120);
  colorSub = new BehaviorSubject('#fff');
  backgroundColorSub = new BehaviorSubject('#000');

  placeholderUrl$ = this.textSub.pipe(
    combineLatestWith(this.widthSub, this.heightSub, this.colorSub, this.backgroundColorSub),
    map(([text, width, height, color, bgColor]) => {
      const encodedText = text ? encodeURIComponent(text) : `${width} x ${height}`;
      const encodedColor = encodeURIComponent(color);
      const encodedBgColor = encodeURIComponent(bgColor);

      return `https://via.assets.so/img.jpg?w=${width}&h=${height}&&tc=${encodedColor}&bg=${encodedBgColor}&t=${encodedText}`;
    })
  );
}
Enter fullscreen mode Exit fullscreen mode

ImagePlaceholderSubjectContainerComponent declares textSub, widthSub, heightSub, colorSub, and backgroundColorSub BehaviorSubject to bind to the inputs of ImagePlaceholderSubjectComponent. I chose BehaviorSubject such that I could combine their values to create a placeholderUrl$ Observable. In the HTML template, I used AsyncPipe to resolve placeholderUrl$ and assigned the URL to an image element to render the image placeholder.

Demo 2: Model Inputs, Signal double binding, and computed Signal

Model inputs are similar to signal inputs except model inputs can do both read and write. Moreover, model inputs have a similar syntax as signal inputs.

text = model('');
Enter fullscreen mode Exit fullscreen mode

text is a model input with an initial value of an empty string

width = model.required<number>();
height = model.required<number>();
Enter fullscreen mode Exit fullscreen mode

width and height are required model inputs; therefore, the parent component needs to provide input values to them

textColor = model('#fff', { alias: 'color' });
bgColor = model('#000', { alias: 'backgroundColor' });
Enter fullscreen mode Exit fullscreen mode

textColor and bgColor are model inputs with initial values and aliases. The parent component binds the values to color and backgroundColor inputs respectively.

// image-placeholder-component.ts

@Component({
  selector: 'app-image-placeholder',
  standalone: true,
  imports: [FormsModule],
  template: `
    <h3>Redo https://dev.me/products/image-placeholder - the new way</h3>
    <div class="container">
      <div class="field">
        <label for="text">
          <span>Text: </span>
          <input id="text" name="text" [(ngModel)]="text" />
        </label>
      </div>
      <div class="field">
        <label for="width">
          <span>Width: </span>
          <input id="width" name="width" [(ngModel)]="width" type="number" min="10" />
        </label>
      </div>
      <div class="field">
        <label for="height">
          <span>Height: </span>
          <input id="height" name="height" [(ngModel)]="height" type="number" min="10" />
        </label>
      </div>
      <div class="field">
        <label for="color">
          <span>Color: </span>
          <input id="color" name="color" [(ngModel)]="textColor" />
        </label>
      </div>
      <div class="field">
        <label for="backgroundColor">
          <span>Background color: </span>
          <input id="backgroundColor" name="backgroundColor" [(ngModel)]="bgColor" />
        </label>
      </div>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ImagePlaceholderComponent {
  text = model('');
  width = model.required<number>();
  height = model.required<number>();
  textColor = model('#fff', { alias: 'color' });
  bgColor = model('#000', { alias: 'backgroundColor' });
}
Enter fullscreen mode Exit fullscreen mode

[(ngModel)]="text" is a new feature that calls "Signal double binding", which means NgModel can read from and write to a WritableSignal/ModelSignal. It is also the shorthand form of [ngModel]="text()" and (ngModelChange)="text.set($event).

// image-placeholder-container.component.ts

@Component({
  selector: 'app-image-placeholder-container',
  standalone: true,
  imports: [ImagePlaceholderComponent],
  template: `
    <h2>New way - with model inputs</h2>
    <app-image-placeholder [(text)]="text"
      [(width)]="width" [(height)]="height"
      [(color)]="color" [(backgroundColor)]="backgroundColor"
    />
    <img [src]="placeholderUrl()" alt="generic placeholder" />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ImagePlaceholderContainerComponent {
// The new way with model inputs
  text = signal('Model Inputs');
  width = signal(250);
  height = signal(120);
  color = signal('#fff');
  backgroundColor = signal('#000');  

  placeholderUrl = computed(() => {
    const text = this.text() ? encodeURIComponent(this.text()) : `${this.width()} x ${this.height()}`;
    const color = encodeURIComponent(this.color());
    const backgroundColor = encodeURIComponent(this.backgroundColor());

    return `https://via.assets.so/img.jpg?w=${this.width()}&h=${this.height()}&&tc=${color}&bg=${backgroundColor}&t=${text}`;
  });
}
Enter fullscreen mode Exit fullscreen mode

ImagePlaceholderContainerComponent declares text, width, height, color, and backgroundColor Signal to bind to the inputs of ImagePlaceholderComponent. placeholderUrl is a computed signal that constructs the URL based on text, width, height, color, and backgroundColor signals. In the HTML template, I assigned placeholderUrl to the image element to render the image placeholder.

// main.ts

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [ImagePlaceholderSubjectContainerComponent,
  ImagePlaceholderContainerComponent],
  template: `
    <h1>Angular {{ version }} - model inputs demo </h1>
    <app-image-plceholder-subject-container />
    <app-image-placeholder-container />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
  version = VERSION.full;
}

bootstrapApplication(App);
Enter fullscreen mode Exit fullscreen mode

App imports both ImagePlaceholderSubjectContainerComponent and ImagePlaceholderContainerComponent, and both of them achieve the same results.

Why I think model inputs are great

  • Less boilerplate code. ImagePlaceholderComponent has a shorter code than ImagePlaceholderSubjectComponent, and the former component is not cluttered by the @input and @Output decorators.

  • Use Signal for synchronous action. Form entry is a synchronous task; therefore, signals are preferred over Observables. Other signals derive the placeholderUrl computed signal and the HTML template displays the result

  • Adoption of [(ngModel)]="signal". With the addition of signal double binding, a signal can easily bind to [(ngModel)]. On the other hand, <behavior subject>.getValue() is passed to [ngModel] input and (ngModelChange)="<behavior subject>.next($event)" emits a new value to the BehaviorSubject. Engineers type fewer characters, and [(ngModel)] is easier to read and comprehend.

The following Stackblitz repo shows the final results:

This is the end of the blog post that analyzes data retrieval patterns in Angular. I hope you like the content and continue to follow my learning experience in Angular, NestJS and other technologies.

Resources:

💖 💪 🙅 🚩
railsstudent
Connie Leung

Posted on February 19, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related