Connie Leung
Posted on February 19, 2024
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);
}
}
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>();
}
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>();
[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)" />
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}`;
})
);
}
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('');
text
is a model input with an initial value of an empty string
width = model.required<number>();
height = model.required<number>();
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' });
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' });
}
[(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}`;
});
}
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);
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 thanImagePlaceholderSubjectComponent
, 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 resultAdoption 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:
Posted on February 19, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.