The Role of the `ngOnInit` Hook

The ngOnInit hook has been a staple in the Angular CLI's component blueprint for as long as most of us can remember. However, the future of this default inclusion is being questioned. In recent discussions within the Angular community, there is speculation about dropping the hook from the standard generator. This is backed by a poll released by the Angular team itself, which, as of now, shows a near 50:50 split in opinion on whether the generated components should include ngOnInit.

At first glance, getting rid of such a familiar hook might seem counterintuitive. But there is substantial reasoning behind this consideration. This piece will demonstrate why ngOnInit is frequently unnecessary, how to migrate current code away from it, and why its usage has become rare in large-scale enterprise solutions.

My goal is to persuade you that:

  • ngOnInit is redundant when working with Observables
  • ngOnChanges is often the more suitable choice over ngOnInit

Our discussion will also cover the key differences between ngOnInit and the constructor.

Understanding `ngOnInit`

The expectation that ngOnInit is always there, ready to accommodate a wall of code, is a habit we've developed. Unfortunately, this has resulted in various misunderstandings, inappropriate usage, and the propagation of anti-patterns across projects.

The official Angular documentation describes the OnInit hook as:

A lifecycle hook that is called after Angular has initialized all data-bound properties of a directive. Define an ngOnInit() method to handle any additional initialization tasks.

This definition suggests that all initialization logic must reside there. We often feel compelled to avoid the constructor due to complex logic. Yet, by leveraging other hooks correctly, utilizing Observables, or employing state management like NgRx, we can often place our logic in more appropriate locations than ngOnInit.

It is crucial to recognize that ngOnInit often gets overloaded with function calls and subscriptions, muddling the line between template and class responsibilities, which ultimately leads to a setup that is convoluted and hard to follow.

Angular components should prioritize simplicity and clarity. We have many tools at our disposal to achieve this: breaking down large components, using pipes, directives, lifecycle hooks, and Observables.

In the sections that follow, we will explore scenarios where ngOnInit is used without genuine necessity, and discuss alternative strategies.

Why Not Just Stick with the `constructor`?

One might think we could simply relocate all logic from ngOnInit into the constructor. But it isn't that straightforward.

The constructor and ngOnInit serve distinctly different purposes. Max, a fellow writer, has an excellent explanation on this topic, available here. The primary point is that the constructor runs when Angular is building the components tree, whereas ngOnInit executes post-change detection with all bindings updated. During construction, dependency injection is resolved, but the DOM is not ready; we cannot access input properties, DOM elements, or child components. Those are available in ngOnInit, while other dynamic elements only appear in onAfterViewInit.

Initialization logic that depends on DOM, inputs, or bindings cannot be placed in the constructor as they are not available at that point. In general, setup during construction time is restricted to dependency injection and routing configuration.

Managing Subscriptions

It is common for components to fetch data asynchronously before displaying it, often through Observables—the primary mechanism in Angular for handling async operations, including HttpClient requests. Once this data arrives, we have choices: subscribe to the Observable to extract the value, or convert it into a Promise and await the result.

If you are currently resorting to ngOnInit solely for subscriptions, here is a potentially better approach.

Many developers, despite using Observables, don't fully embrace the paradigm. A common pattern involves subscribing to an Observable at the earliest opportunity and assigning the result to a component property—usually inside ngOnInit. During code reviews, I have frequently encountered snippets similar to this:

data: MyData;

constructor(private readonly dataService: DataService) { }

ngOnInit() {
  this.dataService.getData().subscribe(
    data => this.data = data,
  );
}

While this code works and allows the data property to be rendered in the template, it comes with several downsides.

First, the data property is inaccurately typed. Before the Observable emits a value, the property is actually undefined. Consequently, rigorous typing should be:

data: MyData | undefined;
// or
data?: MyData;

This is misleading. We don't want the property to be undefined as a permanent state, but since we don't know the exact timing of emission, it is effectively optional.

Alternatively, you can assign a default value, with the type annotation accurately reflecting that:

data: MyData | null = null
// or
data: MyData = InitialMyData; // some object with init state

When we subscribe within a component, it's essential to clean up to prevent memory leaks, inadvertently expanding the code footprint with boilerplate:

data: MyData;

private readonly onDestroy = new Subject();

ngOnInit() {
  this.dataService.getData()
    .pipe(
      takeUntil(this.onDestroy)
    ).subscribe(
      data => this.data = data,
    );
}

ngOnDestroy() {
  this.onDestroy.next();
  this.onDestroy.complete();
}

There is also the uncertainty of when data arrives. This becomes problematic when methods in the component depend on this.data—there is no guarantee that the value exists at the time these methods run.

Moreover, there's a risky assumption that the value is delivered synchronously, which, while occasionally correct, more often leads to failures and bugs. This pattern appears often in reviews:

data: MyData;
someValue: string;

ngOnInit() {
  this.dataService.getData().subscribe(
    data => this.data = data,
  );

  // this.data will be most likely undefined at this point 
  // and will throw an Error
  this.someValue = this.data.value; 
}

While moving the someValue assignment inside the subscribe callback resolves the immediate issue, it doesn't protect against future errors of a similar nature.

Refactoring Subscriptions

Templates in Angular can handle more than fixed, static values. We can bind directly to Observables using the AsyncPipe, which subscribes to the Observable stream and returns emitted values, automatically handling unsubscription when the component is destroyed. This not only reduces component complexity but also eliminates the need for ngOnInit in many cases.

Without direct subscriptions in the component class, we can avoid the associated lifecycle management and write more straightforward code.

Consider this component class—it has no subscription and no ngOnInit:

@Component({...})
export class Component {
  
  readonly data$ = this.dataService.getData();
	
  constructor(private readonly dataService: DataService) { }
		
}

This approach yields several tangible benefits:

  1. The property data$ is defined from the outset—the Observable assignment occurs during the constructor—so it loses its optional quality.
  2. Since it is set during component creation, data$ can be marked readonly, ensuring its reference is never overwritten.
  3. No explicit subscription in the class means ngOnDestroy and manual unsubscription are unnecessary.
  4. We eliminate ambiguous properties that might be undefined at any given time, removing uncertainty around their usage in the logic.

The template now becomes the location for the subscription, right where the data is needed:

<p> {{ (data$ | async).someValue }} </p>

The paragraph inside the template renders whenever the data$ Observable emits a new value.

Handling Observables this way significantly improves readability and leaves less room for errors in async processing.

Is the `constructor` Solely for DI?

Yes—and важно to recall that simply assigning an Observable to a property in the constructor doesn't trigger any logic unless we subscribe to it. The subscription happens later, in the template via AsyncPipe. Therefore, none of our processing code runs at the moment of component creation.

Handling Data and Nested Subscriptions

You might point out that while the approach above works well, it breaks down when we need to process the data or combine multiple sources. It's also understandable that adding | async throughout the template might feel clumsy.

Let's address these advanced scenarios step by step.

Processing the Data

Consider a case where the API returns a large object, but we only need a small part of it—possibly reshaped. For example, the API returns:

interface MyDTO {
  data: {
    name: string;
    time: string;
  }[]
}

An object containing an array where each item has name and time fields. Assume we're particularly interested in the first item, and we must also transform the time from a string to a Date instance.

Here is how you might implement this following the traditional subscribe pattern:

data?: { name: string, time: Date };

constructor(private readonly dataService: DataService) { }

ngOnInit() {
  this.dataService.getData().subscribe(
    response => {
      const first = response.data[0];
      
      this.data = {
        name: first.name,
        time: new Date(first.time)
      }
    }
  );
}

We've omitted unsubscription for brevity. The code subscribes, and once the event if pushed, creates a new object using the data from the response—specifically the first item.

Refactoring the Data Processing

How can we refactor this to leverage functional, stream-based composition? The trick lies in not operating on raw values, but instead transforming the stream itself with rules that execute when data emits.

In RxJs, we use pipeable operators provided to the pipe method. Each operator takes an Observable, applies a transformation, and output another Observable for the next operator in the chain.

The map operator is particularly useful: it projects each value emitted by the source Observable through a provided function (map reference here).

Our refactored example uses pipe and map:

readonly data$ = this.dataService.getData().pipe(
  map((response) => {
    const first = response.data[0];
    
    return {
      name: response.name,
      time: new Date(response.time)
    }
  }),
);

constructor(private readonly dataService: DataService) { }

The function inside map defines our transformation logic, mirroring what we had in the subscribe block preceding:

response => {
  const first = response.data[0];
  
  return {
    name: response.name,
    time: new Date(response.time)
  }
}

This transformation takes the response, extracts the first item from the array, and builds an object from its properties.

It remains easy to consume in the template:

<p> {{ (data$ | async).name }} </p>

Handling Nested Observables Without Nested Subscriptions

Another frequent scenario involves an API call that depends on data extracted from routing — for example, an identifier of a resource. Consider a route where 1257623 represents a hero's ID:

localhost:4200/hero/1257623

The ID must be pulled from the router, after which we fetch the hero information. Below is how one might implement this with explicit subscriptions:

hero?: { name: string };

constructor(
  private readonly route: ActivatedRoute,
  private readonly heroService: HeroService,
) { }

ngOnInit() {
  const id = this.route.snapshot.params.id;

  this.heroService.getHero(id).subscribe(
    response => this.hero = response
  );
}

What issues arise with this pattern? If the URL is updated with a different ID while the component is active, the displayed hero remains stale, and no new API request occurs. The logic is straightforward: the component reads the ID from the route snapshot once during initialization, ignoring any subsequent values. Consequently, HeroService is never invoked again — the component is already rendered, and ngOnInit executes only a single time.

This design choice can be confusing at first glance; you can find a deeper explanation here.

One possible enhancement is to listen for parameter changes by treating params as an Observable:

hero?: { name: string };

constructor(
  private readonly route: ActivatedRoute,
  private readonly heroService: HeroService,
) { }

ngOnInit() {
  this.route.params.subscribe(
    (params) = > {
      const id = params.id;

      this.heroService.getHero(id).subscribe(
        response => this.hero = response
      );
    }
  )	
}

Notice how the code quickly expands, and the nesting makes comprehension harder (not to mention the omitted unsubscribe logic at this stage!).

Refactoring the Nested Streams

At first glance, this may seem distinct from the earlier data-processing example, but conceptually it is identical. We're transforming Observables. Previously, we shaped a response; now we need to merge two Observables.

The solution involves flattening with pipeable operators — this time, a different one. We'll use switchMap, which enables mapping a value from an outer Observable to an inner Observable. For a more thorough explanation, refer to this reference. Here's how the implementation could appear:

readonly hero$ = this.route.params.pipe(
  switchMap(params => this.heroService.getHero(params.id))
);

constructor(
  private readonly route: ActivatedRoute,
  private readonly heroService: HeroService,
) { }

I'm confident the corresponding template code is now self-evident:

<p> {{ (hero$ | async).name }} </p>

Introducing the View Model (vm$)

The final topic addresses the overuse of | async pipes in templates, which can clutter the markup or even introduce performance concerns.
This issue typically emerges from two scenarios:

When a single Observable is referenced across multiple elements, for instance:

<p> {{ (hero$ | async).name }} </p>
<p> {{ (hero$ | async).surname }} </p>
<p> {{ (hero$ | async).city }} </p>

When a component manages multiple Observables, for instance:

readonly hero$ = this.route.params.pipe(
  switchMap(params => this.heroService.getHero(params.id))
);

readonly pet$ = this.route.params.pipe(
  switchMap(params => this.heroService.getPet(params.id))
);

readonly cities$ = this.heroService.getCities();

constructor(
  private readonly route: ActivatedRoute,
  private readonly heroService: HeroService,
) { }

To address this, we turn to the NgIf directive from Angular. While commonly used for conditional rendering, NgIf also supports storing the evaluated result in a local variable using the as keyword. The syntax is shown below:

<p *ngIf="hero.isAlive as alive"> {{ alive }} </p> 

In the paragraph above, we used the local variable rather than repeating the full hero.isAlive expression.

This technique can capture the output of the AsyncPipe once and reuse it across multiple parts of the template, ensuring a single subscription.
Applying this refactor to the first example yields:

<ng-container *ngIf="hero$ | async as hero">
  <p> {{ hero.name }} </p>
  <p> {{ hero.surname }} </p>
  <p> {{ hero.city }} </p>
</ng-container>

We're using an ng-container element here to avoid injecting unnecessary nodes like <div> elements into the DOM (which might be a tempting alternative).

For the second scenario, where multiple Observables are in play, we can merge them into one. This is the View Model pattern — creating a dedicated model for the template's consumption. The conventional abbreviation is vm, which often becomes the property name. Further insights into this architecture are available in this inDepth article.

The combination can be achieved with a different class of RxJS utilities. Pipeable operators transform one Observable into another, whereas creation operators construct new Observables and are used as standalone functions.

The operator we need is combineLatest, which merges multiple Observables, emitting an array representing the latest value from each source. Detailed documentation is available here.
Given that the emitted value is an array, we'll also employ the map operator to convert that array into a more digestible object for the template. Here is the complete View Model logic:

readonly vm$ = combineLatest([
  this.route.params.pipe(
    switchMap(params => this.heroService.getHero(params.id))
  ),
  this.route.params.pipe(
    switchMap(params => this.heroService.getPet(params.id))
  ),
  this.heroService.getCities(),
]).pipe(
  map(([hero, pet, cities]) => {
    return {
      hero,
      pet,
      cities
    }
  })
);


constructor(
  private readonly route: ActivatedRoute,
  private readonly heroService: HeroService,
) { }

The code might seem overwhelming, so let's break it down into two segments:

readonly vm$ = combineLatest([
  this.route.params.pipe(
    switchMap(params => this.heroService.getHero(params.id))
  ),
  this.route.params.pipe(
    switchMap(params => this.heroService.getPet(params.id))
  ),
  this.heroService.getCities(),
])

This is the core logic, utilizing combineLatest to build a vm$ Observable from three distinct source Observables — namely hero, pet, and cities.

The second segment:

.pipe(
  map(([hero, pet, cities]) => {
    return {
      hero,
      pet,
      cities
    }
  })
);

This simply transforms the resulting array into an object with descriptive property names.

Now, let's inspect the template usage:

<ng-container *ngIf="vm$ | async as vm">
  <p> {{ vm.hero.name }} </p>
  <p> {{ vm.hero.surname }} </p>
  <p> {{ vm.hero.city }} </p>
  
  <p> {{ vm.pet.name }} </p>
  
  <ul>
    <li *ngFor="let city of vm.cities"> {{ city }} </li>
  </ul>
</ng-container>

Everything is contained within the vm$ Observable. Once resolved by the | async pipe, our syntax simplifies tremendously — we're just operating on a standard object labeled vm!

Initialization Dependent on Input Values

Stepping away from Observables and subscriptions, there are other scenarios where ngOnInit is used, yet a different hook is more appropriate.

Components, especially presentational ones, often rely on input properties to receive state from container or parent components. They must react to input data and render the appropriate output. Initially, it's easy to treat inputs as static configuration information—used during setup and then disregarded.

That approach can work, but it often fails when the component is later used with dynamic inputs that change over time (e.g., in response to route parameter updates). If a component needs to compute something based on its inputs, it's far more robust to create a solution that responds to every change, not just the initial one.

Below is an example of a component that works correctly at startup but fails to update when its inputs change:

@Component({
  template: `<p> {{ fullName }} </p>`,
})
export class NameComponent implements OnInit {
  @Input() name: string;
  @Input() surname: string;

  fullName: string;

  ngOnInit() {
    this.fullName = `${this.name} ${this.surname}`;
  }
}

The fullname property gets assigned once from the initial name and surname values. Subsequent updates to either name or surname will not be reflected in fullname.

When reacting to changing input data is required, there are two primary approaches:

  1. Utilize the dedicated lifecycle hook — ngOnChanges
  2. Employ the setter technique

Employing ngOnChanges

The ngOnChanges hook fires on every occasion an input property changes, making it suitable for updating internal state based on incoming data. It functions like ngOnInit initially, but also reacts to subsequent modifications. The code below illustrates the concept:

@Component({
  template: `<p> {{ fullName }} </p>`,
})
export class NameComponent implements OnChanges {
  @Input() name: string;
  @Input() surname: string;

  fullName: string;

  ngOnChanges() {
    this.fullName = `${this.name} ${this.surname}`;
  }
}

Comparing ngOnInit and ngOnChanges

The ngOnInit hook is invoked immediately after the first call to ngOnChanges. They share similarities, except that ngOnChanges continues to respond to any future changes.
Furthermore, ngOnChanges provides access to a SimpleChange object containing both the previous and current values for each affected input.

While ngOnInit might seem adequate for initial setup, it's often insufficient because when inputs change, we frequently need to tear down and rebuild existing instances — making ngOnChanges a more suitable choice.

Using Getters for Derived State

While the earlier example was relatively straightforward, real-world logic is typically more intricate. However, if your need is as simple as creating a derived value from input properties, like concatenating two strings, then skipping ngOnChanges entirely might be simpler.

For these basic cases, getters can compute the value on demand. Ensure you are using ChangeDetection.OnPush in such situations to prevent performance degradation if the getter's logic is complex.

The getter-based implementation looks like this:

@Component({
  template: `<p> {{ fullName }} </p>`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NameComponent {
  @Input() name: string;
  @Input() surname: string;

  get fullName(): string {
    return `${this.name} ${this.surname}`
  }
}

Applying Setter Logic

The third approach harnesses TypeScript setters. The Input decorator is not required to be placed on a property directly; it can also decorate a setter. This allows us to embed lightweight setup logic directly within the setter. Consider a scenario where we receive an array via input, needing to transform it — like extracting the first element and converting a date string into a proper Date object.

interface MyDTO {
  data: {
    name: string;
    time: string;
  }[]
}

@Component({
  template: `<p> {{ time }} </p>`,
})
export class TimeComponent {
  @Input() 
  set vm(value: MyDTO) {
    const first = value.data[0];
   
    this.time = new Date(first.time);
  }

  time: Date;
}

With this technique, we eliminate the need for lifecycle hooks to configure the component's state. Data processing happens upon assignment. A key advantage is that this logic triggers only when that specific property changes, whereas ngOnChanges fires for *any* input change, even when we have no setup logic for other properties.

Keep in mind that this pattern is most effective when the resulting state depends on a single input. Using ngOnChanges ensures that the change detection cycle has completed, providing consistency. Setters, on the other hand, can execute mid-cycle during the change detection process.

Summary

This has been a comprehensive exploration, but I trust it has offered some concrete refactoring strategies to make component code more concise, clearer, and simpler to maintain.

Asynchronous data is an inevitability in our components, and mastering its handling is crucial for performance, correctness, and readability. Whether you favor Promises or Observables, if you use the latter, remember that direct subscriptions are frequently unnecessary. Nesting subscribes is almost always avoidable — the key lies in a solid grasp of a few RxJS operators.

So, what should be left out of the ngOnInit block?

  • subscriptions when the Observable can be consumed directly in the template
  • setup logic that must run on every input property update, not just the first cycle

When is ngOnInit the most appropriate choice?

  • when initializing third-party libraries that need references to HTML elements obtained via static ViewChild queries
  • when additional one-time initialization logic is required that can't be replicated with Observables in the template
  • when working with the Promise API

Honestly, ngOnChanges is often a better alternative than ngOnInit. It handles change reactivity while still allowing for one-time setup logic, mirroring ngOnInit functionality. It also offers greater flexibility, such as tearing down and recreating third-party instances with fresh state.

A special case exists for components with no inputs at all — in that situation, ngOnChanges is pointless, and ngOnInit is certainly the right hook.

One caveat when using ngOnChanges: mutating component properties after change detection finishes can trigger an Expression has changed after it was checked error. To avoid this, defer the work until later using mechanisms like requestAnimationFrame, setTimeout, or a Promise to schedule tasks on the micro or macro queue.


It appears we can indeed operate without the auto-generated ngOnInit in Angular CLI's default component scaffold.