Examples of new signal inputs in Angular

Connie Leung - Jan 17 - - Dev Community

Introduction

In this blog post, I would like to show a new feature of Angular 17.1.0 that is called Signal input. New signal input is important in Angular because it can do things that previous version cannot. For example, signal input facilitates construction of computed signals. Signal input also allows API to use it as path/query parameter in effect to set writable signals.

In this blog post, I am going to show the following practical examples using Pokemon API

  • required signal input
  • computed signals based on signal inputs
  • signal input with initial value
  • transformed signal input
  • call signal input + API in effect()
  • signal input + withComponentInputBinding
  • signal input + computed signals + host property in directive
  • signal input + RxJS interop

Install new Angular dependencies

"@angular/core": "17.1.0-rc.0",
"@angular/forms": "17.1.0-rc.0",
"@angular/common": "17.1.0-rc.0",
"@angular/router": "17.1.0-rc.0",
"@angular/compiler": "17.1.0-rc.0",
"@angular/animations": "17.1.0-rc.0",
"@angular/platform-browser": "17.1.0-rc.0"
Enter fullscreen mode Exit fullscreen mode

In package.json of the Stackblitz demo, I update Angular dependencies to 17.1.0-rc.0.

Required signal input

// pokemon.component.ts

import { ChangeDetectionStrategy, Component, input } from '@angular/core';

@Component({
  selector: 'app-pokemon',
  standalone: true,
  imports: [PokemonCardComponent],
  ],
  template: `
    <p>Pokemon id: {{ id() }}</p>
    <hr />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonComponent {
  id = input.required<number>();
}
Enter fullscreen mode Exit fullscreen mode

PokemonComponent has a required signal input, id, that expects a number. This numerical value is then displayed in inline template.

// main.ts

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [PokemonComponent, RouterOutlet, RouterLink, FormsModule],
  template: `
    <div>
      <app-pokemon [id]="25"  />
      <app-pokemon [id]="52" />
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class App {}
Enter fullscreen mode Exit fullscreen mode

In App component, I import PokemonComponent and pass number to pokemon id to the required signal input

Computed signal based on signal input

// pokemon.componen.ts

import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';

@Component({
  selector: 'app-pokemon',
  standalone: true,
  template: `
    <p>Pokemon id: {{ id() }}, Next Pokemon id: {{ nextId() }}</p>
    <hr />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonComponent {
  id = input.required<number>();
  nextId = computed(() => this.id() + 1);
}
Enter fullscreen mode Exit fullscreen mode

nextId is a computed signal that increments id signal input by 1. When id is 25, nextId signal holds 26. When id is 52, nextId signal holds 53. In the inline template, id signal input and nextId computed signal are displayed.

Signal Input with initial value

// pokemon.componen.ts

import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';

@Component({
  selector: 'app-pokemon',
  standalone: true,
  template: `
    <p>Pokemon id: {{ id() }}, Next Pokemon id: {{ nextId() }}</p>
    <p [style.background]="bgColor()">
      Background color: {{ bgColor() }}
    </p>
    <hr />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonComponent {
  id = input.required<number>();
  bgColor = input('cyan', { alias: 'backgroundColor' });

  nextId = computed(() => this.id() + 1);
}
Enter fullscreen mode Exit fullscreen mode

In the component, I declare a signal input with initial value, cyan, and is given an alias backgroundColor. When the component does not have backgroundColor input, the value becomes cyan. Otherwise, the value of backgroundColor is overwritten. The inline template displays the result of bgColor() and uses the same result to change the background colour of the paragraph.

// main.ts

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [PokemonComponent, RouterOutlet, RouterLink, FormsModule],
  template: `
    <div>
      <app-pokemon [id]="25"  />
      <app-pokemon [id]="52" backgroundColor="yellow" />
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class App {}
Enter fullscreen mode Exit fullscreen mode

Transformed Signal Input

// pokemon.componen.ts

import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';

@Component({
  selector: 'app-pokemon',
  standalone: true,
  template: `
    <p>Pokemon id: {{ id() }}, Next Pokemon id: {{ nextId() }}</p>
    <p [style.background]="bgColor()">
      Background color: {{ bgColor() }}
    </p>
    <p>Transformed: {{ text() }}</p>
    <hr />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonComponent {
  id = input.required<number>();
  bgColor = input('cyan', { alias: 'backgroundColor' });
  text = input<string, string>('', {
    alias: 'transformedText',
    transform: (v) => `transformed ${v}!`,
  });

  nextId = computed(() => this.id() + 1);
}
Enter fullscreen mode Exit fullscreen mode

In the component, I declare another signal input with an empty string, and is given an alias transformedText and a transform function. The transform function accepts the string, prepends 'transformed' and appends an exclamation mark to it. Similarly, the inline template displays the result of text() that is the value after the transformation.

// main.ts

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [PokemonComponent, RouterOutlet, RouterLink, FormsModule],
  template: `
    <div>
      <app-pokemon [id]="25"  transformedText="red" />
      <app-pokemon [id]="52" backgroundColor="yellow" transformedText="green" />
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class App {}
Enter fullscreen mode Exit fullscreen mode

The first PokemonComponent displays "transformed red!" while the second PokemonComponent displays "transformed green!".

The next three examples demonstrates advanced usage of signal input

Call signal input + API in effect()

// get-pokemon.util.ts

import { HttpClient } from '@angular/common/http';
import { assertInInjectionContext, inject } from '@angular/core';
import { PokemonType } from '../types/pokemon.type';

export function getPokemonFn() {
    assertInInjectionContext(getPokemonFn);
    const httpClient = inject(HttpClient);
    const URL = `https://pokeapi.co/api/v2/pokemon`;

    return function (id: number) {
      return httpClient.get<PokemonType>(`${URL}/${id}/`)
    }
}
Enter fullscreen mode Exit fullscreen mode
// pokemon.componen.ts

@Component({
  selector: 'app-pokemon',
  standalone: true,
  imports: [PokemonCardComponent],
  template: `
     ... omitted unrelated codes ...

     <div class="container">
       @if (pokemon(); as pokemon) {
           <app-pokemon-card [pokemon]="pokemon" />
        }

        @if (nextPokemon(); as pokemon) {
             <app-pokemon-card [pokemon]="pokemon" />
         }
    </div>
    <hr />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonComponent {
  id = input.required<number>(); 
  nextId = computed(() => this.id() + 1);
  getPokemon = getPokemonFn();

  pokemon = signal<PokemonType | undefined>(undefined);
  nextPokemon = signal<PokemonType | undefined>(undefined);

  constructor() {
    effect((onCleanup) => {
      const sub = this.getPokemon(this.id())
        .subscribe((pokemon) => this.pokemon.set(pokemon));
      const sub2 = this.getPokemon(this.nextId())
        .subscribe((pokemon) => this.nextPokemon.set(pokemon));

      onCleanup(() => {
        sub.unsubscribe();
        sub2.unsubscribe();
      });
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

In effect(), the callback function invokes this.getPokemon function to retrieve Pokemon by id signal input and nextId computed signal respectively. When the first Observable subscribes, the Pokemon object is set to pokemon signal. When the second Observable subscribes, the Pokemon object is set to nextPokemon signal. Moreover, the onCleanup function unsubscribes the subscriptions to avoid memory leak. In the inline template, the new control flow tests the signals are defined before passing the data to PokemonCardComponent to handle the rendering.

signal input + withComponentInputBinding

// app.config.ts

import { provideHttpClient } from '@angular/common/http';
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(),
    provideRouter(routes, withComponentInputBinding())
  ]
};
Enter fullscreen mode Exit fullscreen mode

provideRouter has withComponentInputBinding feature; therefore, route data is easily converted to signal input in component.

// app.route.ts

import { Routes } from '@angular/router';

export const routes: Routes = [
    {
        path: 'pokemons/pidgeotto',
        loadComponent: () => import('./pokemons/pokemon/pokemon.component').then((m) => m.PokemonComponent),
        data: {
            id: 17,
            backgroundColor: 'magenta',
            transformedText: 'magenta',
        }
    },
];
Enter fullscreen mode Exit fullscreen mode

In 'pokemons/pidgeotto' route path, I supply route data, id, backgroundColor and tranformedText. Since withComponentInputBinding is enabled, the route data automatically converts to id, bgColor and text signal inputs.

// main.ts

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [PokemonComponent, RouterOutlet, RouterLink, FormsModule],
  template: `
    <div>
    <h2>Signal inputs with route data</h2>
    <ul>
         <li><a [routerLink]="['/pokemons/pidgeotto']">pidgeotto</a></li>
    </ul>
    <router-outlet />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class App {}
Enter fullscreen mode Exit fullscreen mode

When user clicks on the hyperlink, user is routed to PokemonComponent to retrieve pidgeotto and pidgeot from the Pokemon API.

signal input + computed signals + host property in directive

// font-size.directive.ts

import { computed, Directive, input } from '@angular/core';

@Directive({
  selector: '[appFontSize]',
  standalone: true,
  host: {
    '[style.font-size.px]': 'size()',
    '[style.font-weight]': 'fontWeight()',
    '[style.font-style]': 'fontStyle()',
    '[style.color]': 'color()'
  },
})
export class FontSizeDirective {
  size = input(14);
  shouldDoStyling = computed(() => this.size() > 20 && this.size() <= 36);
  fontWeight = computed(() => this.shouldDoStyling() ? 'bold' : 'normal');
  fontStyle = computed(() => this.shouldDoStyling() ? 'italic' : 'normal');
  color = computed(() => this.shouldDoStyling() ? 'blue' : 'black');
}
Enter fullscreen mode Exit fullscreen mode

I create a font size directive to update the font size, font weight, font style and color of HTML DOM elements. size is a signal input with an initial value 14. Whenever size updates, shouldDoStyling determines whether or not CSS styling should apply.

  • fontWeight - a computed signal that returns either bold or normal
  • fontStyle - a computed signal that returns italic or normal
  • color - a computed signal that returns blue or black
// pokemon.component.ts

@Component({
  selector: 'app-pokemon',
  standalone: true,
  imports: [PokemonCardComponent],
  hostDirectives: [
    {
      directive: FontSizeDirective,
      inputs: ['size'],
    }
  ],
  template: `
    <p>Pokemon id: {{ id() }}, Next Pokemon id: {{ nextId() }}</p>
    <p [style.background]="bgColor()">
      Background color: {{ bgColor() }}
    </p>
    <p>Transformed: {{ text() }}</p>

     // omitted because the code is irrelevant
    <hr />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonComponent {
  id = input.required<number>();
  bgColor = input('cyan', { alias: 'backgroundColor' });
  text = input<string, string>('', {
    alias: 'transformedText',
    transform: (v) => `transformed ${v}!`,
  });

  nextId = computed(() => this.id() + 1);

  // omitted because the code is irrelevant
}
Enter fullscreen mode Exit fullscreen mode

PokemonComponent registers FontSizeDirective as a host directive with a size input. CSS styling applies to the paragraph elements when App component assigns a value to the size input.

// main.ts

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [PokemonComponent, RouterOutlet, RouterLink, FormsModule],
  template: `
    <div>
      <label for="size">
        <span>Size: </span>
        <input type="number" id="size" name="size" [ngModel]="size()" (ngModelChange)="size.set($event)" min="8" />
      </label>
      <app-pokemon [id]="25" transformedText="red" [size]="size()" />
      <app-pokemon [id]="52" backgroundColor="yellow" transformedText="green" [size]="size()" />
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class App {
  size = signal(16);
}
Enter fullscreen mode Exit fullscreen mode

App component has a template-driven form with a number field. When user inputs a new size, the size input of PokemonComponent affects the styling of the paragraph elements.

There is one example left that is the signal input and RxJS interop.

// pokemon.component.ts

@Component({
  selector: 'app-pokemon',
  standalone: true,
  imports: [PokemonCardComponent, AsyncPipe],
  hostDirectives: [
    {
      directive: FontSizeDirective,
      inputs: ['size'],
    }
  ],
  template: `
    <p>Observable: {{ value$ | async }}</p>
    <hr />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonComponent {
  id = input.required<number>();
  bgColor = input('cyan', { alias: 'backgroundColor' });
  text = input<string, string>('', {
    alias: 'transformedText',
    transform: (v) => `transformed ${v}!`,
  });

  value$ = toObservable(this.bgColor)
    .pipe(
      combineLatestWith(toObservable(this.text)),
      map(([color, color2]) => `${color}|${color2}`),
      map((color) => `@@${color.toUpperCase()}@@`),
    );
}
Enter fullscreen mode Exit fullscreen mode

value$ is an Observable that combines toObservable(this.bgColor) and toObservable(this.text) to transform the signal inputs into a new text. In the inline template, I use the async pipe to resolve value$ and display its final value

These are all the practical examples that can achieve using the new signal input in Angular 17.1.0.

The following Stackblitz repo shows the final results:

This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular and other technologies.

Resources:

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .