angularangular-signals

Using toSignal() with a required Input Signal


I have an Angular 20 application with a Component that fetches logs from a bookService based on a required Input. The service returns an Observable.

I tried to transform the Observable from the service with toSignal(), like this:

export class HistoryComponent {
  private readonly bookService = inject(BookService);
  public readonly bookId = input.required<string>();

  public logs = toSignal(
    this.bookService.getLogs(this.bookId()),
    {initialValue: []}
  );
}

But this gives me the following error:

RuntimeError: NG0950: Input "bookId" is required but no value is available yet. Find more at https://angular.dev/errors/NG0950
    at _HistoryComponent.inputValueFn [as bookId] (core.mjs:61:19)
    at <instance_members_initializer> (history.component.ts:21:55)
    at new _HistoryComponent (history.component.ts:16:7)

It seems that toSignal() requires a value of the input signal to be available at construction. My options now seem to be as follows:

  1. Use toSignal() in the ngOnInit lifecycle-hook to be sure to have a value for the bookId input signal. But there I have no Injection context available (which is needed for toSignal), so I would need withInjectionContext() which would be a bit hacky.
  2. Make the bookId input not required, and handle empty values to not make the service call. This doesn't feel right, as I can make sure the input is always set by the parent component, so it should be required.
  3. Use effect() instead of toSignal(), see below.

The first two solutions feel very wrong, so I went with the effect() - currently I have the following working solution:

export class HistoryComponent {
  private readonly bookService = inject(BookService);
  public readonly bookId = input.required<string>();

  public logs: BookLogs[] = [];

  public constructor() {
    effect(() => this.bookService.getLogs(this.bookId())
      .subscribe(logs => this.logs = logs));
  }
}

Is this really current best practice? I hope there's a better solution which uses toSignal().


Solution

  • We can check the Angular documentation NG0950 - Required input is accessed before a value is set.

    Fixing the error:

    Access the required input in reactive contexts. For example, in the template itself, inside a computed, or inside an effect.
    Alternatively, access the input inside the ngOnInit lifecycle hook, or later.


    Looking at this issue NG0952: Input is required but no value is available yet. #55010 and comment from JeanMache

    JeanMeche on Mar 23, 2024
    Hi, required inputs can only be read after the component is init (after ngOnInit()). By invoking user in the property initialization you're accessing a signal that hasn't been set yet.
    In your case it looks like expired should be a computed input.


    Reason for Error:

    You should not use a toSignal (Since I believe initialization is on a non reactive context - the observable using the signal is initialized in a non reactive context). Since it reads the value before ngOnInit, hence we get the error.

    Fix:

    Instead we can use rxResource which can be used to asynchronously fetch the data. Which executes the params signals in a reactive context, which gets rid of the error.

    @Component({
      selector: 'app-child',
      imports: [CommonModule],
      template: `
      @if(logs.error(); as error) {
        {{error}}
      } @else if(logs.isLoading()) {
        Loading...
      } else {
        {{logs.value() | json}}
      }
      `,
    })
    export class Child {
      private readonly bookService = inject(BookService);
      public readonly bookId = input.required<string>();
    
      public logs = rxResource({
        params: () => this.bookId(),
        stream: ({ params: bookId }) => this.bookService.getLogs(bookId),
        defaultValue: [],
      });
    }
    

    Full Code:

    import { Component, Injectable, inject, input, signal } from '@angular/core';
    import { rxResource } from '@angular/core/rxjs-interop';
    import { bootstrapApplication } from '@angular/platform-browser';
    import { of, delay, Observable } from 'rxjs';
    import { CommonModule } from '@angular/common';
    
    @Injectable({ providedIn: 'root' })
    export class BookService {
      getLogs(id: any): Observable<any> {
        return of({ test: 1 }).pipe(delay(3000));
      }
    }
    
    @Component({
      selector: 'app-child',
      imports: [CommonModule],
      template: `
        @if(logs.error(); as error) {
          {{error}}
        } @else if(logs.isLoading()) {
          Loading...
        } @else {
          {{logs.value() | json}}
        }
      `,
    })
    export class Child {
      private readonly bookService = inject(BookService);
      public readonly bookId = input.required<string | undefined>();
    
      public logs = rxResource({
        params: () => this.bookId(),
        stream: ({ params: bookId }) => this.bookService.getLogs(bookId),
        defaultValue: [],
      });
    }
    @Component({
      selector: 'app-root',
      imports: [Child],
      template: `
        <!-- @if(bookId(); as bookIdVal) { -->
          <app-child [bookId]="bookId()"/>
        <!-- } -->
        <!-- @let bookIdVal2 = bookId() || '';
        <app-child [bookId]="bookIdVal2"/> -->
      `,
    })
    export class App {
      name = 'Angular';
      bookId = signal<string | undefined>(undefined);
    
      ngOnInit() {
        this.bookId.set('1');
      }
    }
    
    bootstrapApplication(App);
    
    

    Stackblitz Demo