New output function - let's talk without a decorator in Angular

Connie Leung - Mar 8 - - Dev Community

Introduction

In this blog post, I would like to show a new feature in Angular 17.3.0-rc.0 that calls the output function. With the new output function, a child component can emit data to the parent without a decorator. Moreover, the return type of the new output function is OutputEmitterRef which can convert to an Observable through the rxjs-interop function, outputToObservable. Similarly, an Observable can convert to an OutputEmitterRef through outputFromObservable function to emit data to its parent.

In this post, I created 2 demos that are clones of the generic image placeholder site (https://dev.me/products/image-placeholder). The demos are designed to demonstrate the usage of output, outputToObservable, and outputFromObservable respectively.

Demo 1: The demo binds the signals to the template-driven form in the child component. When a signal value updates, it emits the value to the RxJS operators to construct the full URL. The URL is later converted to an OutputEmittRef using outputFromObservable. The parent component queries the URL output, converts it to an Observable, and emits the value to the scan operator to count the number of changes.

Demo 2: The demo also binds the signals to the template-driven form in the child component. When a signal value updates, the computed signal recalculates the value of the URL. In the constructor of the child component, the effect uses the new output function to emit the URL to its parent. The parent component queries the URL output, converts it to an Observable, and emits the value to the scan operator to count the number of changes.

Demo 1: outputFromObservable and outputToObservable in action

// image-placeholder.componen.ts

@Component({
  selector: 'app-image-placeholder',
  standalone: true,
  imports: [FormsModule],
  template: `
    <h3>Redo https://dev.me/products/image-placeholder</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)]="color" />
        </label>
      </div>
      <div class="field">
        <label for="backgroundColor">
          <span>Background color: </span>
          <input id="backgroundColor" name="backgroundColor" [(ngModel)]="backgroundColor" />
        </label>
      </div>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ImagePlaceholderComponent {
  text = signal('Signal Output');
  width = signal(400);
  height = signal(120);
  color = signal('#fff');
  backgroundColor = signal('#000');

  placeholderUrl = outputFromObservable(toObservable(this.text)
    .pipe(
      combineLatestWith(toObservable(this.width),
        toObservable(this.height),
        toObservable(this.color),
        toObservable(this.backgroundColor)
      ),
      map(([text, width, height, textColor, bgColor]) => {
        const encodedText = text ? encodeURIComponent(text) : `${width} x ${height}`;
        const color = encodeURIComponent(textColor);
        const backgroundColor = encodeURIComponent(bgColor);

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

ImagePlaceholderComponent has a template-driven form that allows users to input text, width, height, text color, and background color. Each form field has a ngModel directive that reads and writes to the signal.

toObservable(this.text)
    .pipe(
      combineLatestWith(toObservable(this.width),
        toObservable(this.height),
        toObservable(this.color),
        toObservable(this.backgroundColor)
      ),
      map(([text, width, height, textColor, bgColor]) => {
        const encodedText = text ? encodeURIComponent(text) : `${width} x ${height}`;
        const color = encodeURIComponent(textColor);
        const backgroundColor = encodeURIComponent(bgColor);

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

toObservable converts the signals to the Observables and combines the latest values in a new Observable. The new Observable is passed to the map operator to construct the new URL. debounce(200) ensures the URL is only emitted to the parent when it does not change after 200 milliseconds. Therefore, debounce prevents firing too many URL changes to the parent.

// Old

@Output()
placeholderUrl = toObservable(this.text).pipe(....);
Enter fullscreen mode Exit fullscreen mode
// New

placeholderUrl = outputFromObservable(toObservable(this.text).pipe(....));
Enter fullscreen mode Exit fullscreen mode

The old way is to assign the Observable to the placeholderUrl directly and apply the Output decorator to it.

The new way is to pass the Observable to the outputFromObservable function to create an OutputEmitterRef.

Create a parent component for the output event

// main.ts

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [ImagePlaceholderComponent, AsyncPipe],
  template: `
    <app-image-placeholder (placeholderUrl)="url = $event" />
    <p>URL: {{ url }}</p>
    <p>URL Change {{ urlChangeCount$ | async }} times.</p>
    <img [src]="url" alt="generic placeholder" />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App implements OnInit {
  url = '';
  child = viewChild.required(ImagePlaceholderComponent);
  urlChangeCount$!: Observable<number>;

  ngOnInit(): void {
    this.urlChangeCount$ = outputToObservable(this.child().placeholderUrl)
      .pipe(scan((acc) => acc + 1, 0));
  }
}
Enter fullscreen mode Exit fullscreen mode
<app-image-placeholder (placeholderUrl)="url = $event" />
Enter fullscreen mode Exit fullscreen mode

The placeholderUrl output event assigns the value to the url instance member

child = viewChild.required(ImagePlaceholderComponent);
Enter fullscreen mode Exit fullscreen mode

viewChild.required queries the ImagePlaceholderComponent instance in the demo.

ngOnInit(): void {
    this.urlChangeCount$ = outputToObservable(this.child().placeholderUrl)
          .pipe(scan((acc) => acc + 1, 0));
}
Enter fullscreen mode Exit fullscreen mode

outputToObservable(this.child().placeholderUrl) converts, this.child().placeholderUrl, that is an OutputEmitterRef to an Observable. The Observable is passed to the scan operator to count the number of URL changes.

<p>URL Change {{ urlChangeCount$ | async }} times.</p>
Enter fullscreen mode Exit fullscreen mode

urlChangeCount$ Observable resolves and displays the count in the inline template.

Demo 2: Demonstrate the new output function and outputToObservable

// image-placeholder-component.ts

@Component({
  selector: 'app-image-placeholder',
  standalone: true,
  imports: [FormsModule],
  template: `
    <h3>Redo https://dev.me/products/image-placeholder</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)]="color" />
        </label>
      </div>
      <div class="field">
        <label for="backgroundColor">
          <span>Background color: </span>
          <input id="backgroundColor" name="backgroundColor" [(ngModel)]="backgroundColor" />
        </label>
      </div>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ImagePlaceholderComponent {
  text = signal('Output function');
  width = signal(300);
  height = signal(100);
  color = signal('#fff');
  backgroundColor = signal('#000');

  url = 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}`;
  });

  placeholderUrl = output<string>({
    alias: 'url'
  });

  constructor() {
    effect(() => this.placeholderUrl.emit(this.url()))
  }
}
Enter fullscreen mode Exit fullscreen mode
url = 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

url is a computed signal that recalculates when the user changes any form value.

placeholderUrl = output<string>({ alias: 'url' });
Enter fullscreen mode Exit fullscreen mode

placeholderUrl is an OutputEmitterRef of type string and with an alias, url. Moreover, alias is the only property in OutputOptions.

constructor() {
   effect(() => this.placeholderUrl.emit(this.url()))
}
Enter fullscreen mode Exit fullscreen mode

Inside the effect(), the function runs whenever the URL changes and this is the right place to emit the new URL to its parent.

// main.ts

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [ImagePlaceholderComponent, AsyncPipe],
  template: `
    <app-image-placeholder (url)="url = $event" />
    <p>URL: {{ url }}</p>
    <p>URL Change {{ urlChangeCount$ | async }} times.</p>
    <img [src]="url" alt="generic placeholder" />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App implements OnInit {
  url = '';
  child = viewChild.required(ImagePlaceholderComponent);
  urlChangeCount$!: Observable<number>;

  ngOnInit(): void {
    this.urlChangeCount$ = outputToObservable(this.child().placeholderUrl)
      .pipe(scan((acc) => acc + 1, 0));
  }
}
Enter fullscreen mode Exit fullscreen mode
<app-image-placeholder (url)="url = $event" />
Enter fullscreen mode Exit fullscreen mode

Since an alias is applied to the new output function, the output event is renamed to url in the parent. The url output event assigns the value to the url instance member

child = viewChild.required(ImagePlaceholderComponent);
Enter fullscreen mode Exit fullscreen mode

viewChild.required queries the ImagePlaceholderComponent instance in the demo.

ngOnInit(): void {
    this.urlChangeCount$ = outputToObservable(this.child().placeholderUrl)
          .pipe(scan((acc) => acc + 1, 0));
 }
Enter fullscreen mode Exit fullscreen mode

outputToObservable(this.child().placeholderUrl) converts, this.child().placeholderUrl, that is an OutputEmitterRef to an Observable. The Observable is passed to the scan operator to count the number of URL changes.

<p>URL Change {{ urlChangeCount$ | async }} times.</p>
Enter fullscreen mode Exit fullscreen mode

urlChangeCount$ Observable resolves and displays the count in the inline template.

Pros and Cons of both demos

  • Readability: Demo 2 is more readable than Demo 1 because url is a computed signal that builds from the form values. On the other hand, Demo 1 uses toObservable, combineLatestWith, and map to build the same URL.

  • Performance: Demo 1 does not emit the URL as many times as Demo 2 with the help of debounceTime(200). It can improve the performance of Demo 1 because the parent does not frequently request the remote server to get a new image and update the image element.

  • RxJS interop: OutputEmitterRef can pass to the outputToObservable function to convert into an Observable. Then, the component can manipulate the Observable further to create new Observables to display in the template.

The following Stackblitz repos show 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:

  1. Stackblitz Demo with outputFromObservable: https://stackblitz.com/edit/angular-output-fn-mp42ug?file=src%2Fmain.ts
  2. Stackblitz Demo with output(): https://stackblitz.com/edit/angular-output-fn-wb6pwh?file=src%2Fmain.ts
  3. Github Repo: https://github.com/railsstudent/ng-output-demos
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .