Components

Angular inputs & lifecycle

When I do code reviews, I see that this assumption leads to additional problems. People assume that the code that handle changes can only be declared in the ngOnInit method. An untrained developer will assume this scenario quite often.

Angular inputs & lifecycle — Components article by Alain Boudard on Angular In Depth
Angular inputs & lifecycle — Components article by Alain Boudard on Angular In Depth
On this page · 5 sections

Inputs don't always behave the way you'd expect in Angular.

Even years after the Angular 2 rewrite, I still frequently encounter a common misconception about how inputs and lifecycle methods interact. It usually sounds something like this:

The ngOnInit lifecycle hook is the safe place to read input values, because the constructor runs before inputs are assigned. Therefore, avoid reading inputs in the constructor and rely on ngOnInit instead.

In practice, though, this piece of advice doesn't hold up well.

When reviewing code, I often see that this belief creates additional issues. Developers assume that any logic dealing with input changes must be placed inside ngOnInit. That assumption is common, especially among less experienced developers. While it works fine for a component with a hardcoded value, it quickly falls apart for anything more dynamic.

export class ParentComponent {
  pouf = 'pouf';
}

<app-child [pouf]="pouf" />

export class ChildComponent implements OnInit {
  @Input({ required: true })
  pouf!: string;

  ngOnInit(): void {
    // here you have your input value
    console.log(this.pouf);
  }
}

Handle inputs when you actually need them

There are already plenty of articles covering input management strategies — using setter and getter methods, and so on.

Detecting @​Input changes in Angular with ngOnChanges and Setters - by Todd Motto

I'd like to add my own perspective, focusing on why these techniques are necessary.

Let's revisit the earlier example, but this time instead of a simple value, we'll pass an Observable down to the child component.

export class ParentComponent {
  title$ = of('Alain').pipe(delay(2000));
}
<app-child [title]="(title$ | async)!" />

export class ChildComponent implements OnInit {
  
  @Input({ required: true })
  title!: string;

  ngOnInit(): void {
    //Here you have null in your input
    console.log(this.title);
  }
}
  • The Observable is delayed to simulate an API call
  • Inspecting this.title inside ngOnInit reveals that no value is available yet

So you really can't depend on that lifecycle hook for input handling. This is why, whenever possible, we should work with our data reactively — doing so avoids these kinds of pitfalls altogether.

By implementing the OnChanges interface, we can watch for updates and act only when the data is actually present:

ngOnChanges(changes: SimpleChanges) {
  if (changes["title"]) {
    //I know I have the data
    console.log(this.title);
  }
}

Working asynchronously with Inputs

Dealing with asynchronous data requires a different approach. One of the best solutions I've come across is turning Inputs into Observables. The article below demonstrates how to achieve this with a custom annotation:

How to build reactive Angular Components using Inputs as Observables

The code ends up looking like this (taken from that article):

@Component({
  selector: "my-component",
  template: `<p>result: {{ result$ | async }}</p>`,
})
export class MyComponent {
  @Input() prop!: number;

  @Observe("prop") private prop$!: Observable<number>;

  result$ = this.prop$.pipe(
    switchMap((prop) => this.myService.getResult(prop)),
    // share the response across all subscribers to prevent
    // multiple HTTP requests
    share()
  );
}

This pattern hides the complexity of converting an input into an Observable. You no longer need to worry about whether the value is available — you simply write reactive code.

Working with Angular Signal Inputs

But the astute reader will point out that the old annotation API is outdated, and we should be using Signal Inputs instead. Let's explore that, keeping an Observable as the data source.

export class ParentComponent {
  meuh$ = of('meuh').pipe(delay(3000));
}
<app-child [meuh]="(meuh$ | async)!" />

export class ChildComponent implements OnInit, OnChanges {
  meuh = input.required<string>();

  constructor() {
    effect(() => {
      //First run: this input is null
      console.log('Effect : ', this.meuh());
    });
  }

  ngOnInit(): void {
    //Here you have null in your input
    console.log(this.meuh());
  }
}
  • The data source being an Observable doesn't change the core problem. Whether it's a regular Input or a Signal Input, we can't assume the value will be set in the child component.
  • We can still rely on OnChanges to track and consume the incoming value:
ngOnChanges(changes: SimpleChanges) {
  if (changes["meuh"]) {
    //I know I have the data
    console.log(this.meuh());
  }
}

Of course, we probably wouldn't use a Signal Input this way. A more idiomatic approach would involve reactive patterns using effect and computed.

Derive Inputs data

So what are our options when we need to transform input data?

Avoiding ngOnInit entirely, we might turn to setters, though this article highlights some drawbacks. Namely, setters work fine for a single Input, but with multiple Inputs, it becomes difficult to know when all of them have been populated.

Using ngOnChanges, as mentioned before, is a reliable solution, though it isn't the most elegant. The changes object only gives you one Input at a time. Still, it can be used like this:

export class UserComponent implements OnChanges {
  @Input({ required: true })
  name!: string;

  @Input({ required: true })
  id!: number;

  fullName?: string;

  ngOnChanges(changes: SimpleChanges): void {
    // I could use changes['id'] but then I would need to be sure 
    // that the other Input has value
    if (this.id && this.name) {
      this.fullName = this.id + ' - ' + this.name
    }
  }
}

It's easy to see that this code is somewhat "imperative" — we're executing an action as a reaction to an event. With the signals API, we can handle these asynchronous Inputs in a cleaner, more "reactive" way (and with less boilerplate):

export class UserSignalComponent {
  id = input.required<number>();
  name = input.required<string>();
  fullName = computed(() => (this.id() && this.name()) ? 
    this.id() + ' - ' + this.name() : 
    '');
}

Conclusion

If there's one key takeaway here, it's this:

Don't rely on the Angular OnInit lifecycle hook to manage your input data inside components.

Here's a Stackblitz to experiment with: https://stackblitz.com/edit/stackblitz-starters-38fyzb

Also, be sure to check out this excellent article from Angular University about Signal Components in Angular:

Angular Signals Component API: input, output, model (Complete Guide)


Angular inputs & lifecycle — figure 1

Tagged in:

Angular 17, Inputs, Lifecycle

Last Update: July 01, 2024

AB
Alain Boudard

Writes about Components, Testing. Active 2023–2024.

All 2 articles →