You don't want a BaseComponent in your app

agoncharuks

Alexander Goncharuk

Posted on March 9, 2023

You don't want a BaseComponent in your app

In this article, I will do my best to explain why I believe having BaseComponent in your Angular project is a bad option for code sharing. And the more your project evolves, the bigger your regret will be.

While some thoughts in this article are probably applicable to other modern SPA frameworks as well, it is mainly relevant to Angular projects and operates with Angular APIs.

We will also explore several better alternatives available in the framework to keep your code DRY.

Why BaseComponent even exists

Before looking into problems one causes, let's analyze why it is not uncommon to find a class named BaseComponent in different code bases.

Angular on its own is a mix of different software paradigms and OOP is one of them. Hence, it may be tempting to put the repeating logic pieces in a class called BaseComponent that will provide every child component extending it with all the shared functionality needed. Consider the following example:

export abstract class BaseComponent implements OnDestroy {
 protected destroy$ = new Subject<void>;
  protected abstract form: FormGroup;

  constructor(private analyticsService: AnalyticsService,
              private router: Router) {}

  public trackPageVisit() {
    const currentUrl = this.router.url;
    this.analyticsService.pageViewed({url: currentUrl})
  }

  get isFormValid(): boolean {
    return this.form.valid;
  }

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }

  // ... many other shared methods added over time
}
Enter fullscreen mode Exit fullscreen mode

The need to share functionality is one of those needs a developer will likely have in almost every software project. Inheritance is one way to achieve this goal. But using inheritance for this purpose in Angular is a bad idea both because of the problems it creates and because of the alternative powerful techniques this framework offers to achieve the same goal.

Let's start with the problems part.

What is wrong with BaseComponent

It breaks the meaning of inheritance

It is hard to argue with the fact OOP is by far the most widely used programming paradigm in commercial development. This doesn't mean though it is a universal solution to every problem. Inheritance as one of the OOP pillars is meant for Object A is a subtype of Object B relationship. While in BaseComponent it is used for Object A and Object B need a Function C relationship instead.

FullTimeEmployee extends Employee and HourlyRateEmployee extends Employee is a good example of inheritance.

FullTimeEmployee extends WithLogger and HourlyRateEmployee extends WithLogger is a bad example of inheritance.

I guess that such a light-headed take on good OOP practices can be explained by the fact engineers sometimes apply different rules to code in the domain model and code in the view layer. This practice is wrong though and doesn't make it less of an anti-pattern.

💡 Using inheritance, where it should not be used, is wrong regardless of which layer of application it happens in.

You don't get to choose what to inherit

If you have a BaseComponent in your application like the one provided above, you may soon find your application in the following state:

  • Different components using takeUntil(this.destroy$) for subscriptions done in the component classes, will extend the BaseComponent for the sake of its shared unsubscription logic.
  • Only some of these child components need the page tracking method.
  • Only some of these child components contain forms and hence need an ifFormValid getter.
  • Finally, not every component that actually tracks page visits or contains a form does manual subscriptions. Many of them might not need the destroy$ logic at all.

Extending BaseComponent pollutes component classes that now contain every single method and property of the base class. This has no positive effect, but brings the following negative effects:

  • Application pays an extra cost for every component inheriting all the methods of the parent class regardless of if this method is needed or not.
  • Debugging experience gets worse since components are cluttered with non-relevant properties and methods.

Makes it easy to shoot yourself in the foot by rewriting lifecycle hooks

This particular mistake I've seen in almost every Angular project I worked on:

export class MyComponent extends BaseComponent implements OnInit, OnDestroy {
  // ...

  ngOnInit(): void {
    this.subscription$ = this.service.getSubscription$().pipe(
      takeUntil(this.destroyed$)
    ).subscribe();
  }  

  ngOnDestroy(): void {
    this.cleanupMyComponentStuff();
  }
}
Enter fullscreen mode Exit fullscreen mode

Did you spot the problem here? The takeUntil(this.destroyed$) will never work, because destroyed$.next() is called in the extended BaseComponent's ngOnDestroy hook, and we forgot to call super.ngOnDestroy() in the MyComponent's ngOnDestroy body.

How can we make such a naive mistake with all the experience in OOP that the team has? How come the linter rules not catch such a thing? We could have been more careful and could have had an eslint rule for this. But here is the thing: it just happens, believe it or not. And results in memory leaks.

Creates stronger coupling in the app

As your application evolves, you may find yourself in a situation where particular pages of your app qualify to be moved into a separate package. Usually, there are several factors leading to this, like the size of the module and its independence from the rest of the application. Such modules can be developed by dedicated teams and even deployed independently if follow the micro-frontend architecture principles. It can also be just a publishable library exporting modules or components that belong to one business domain, or a library in a monorepo that is not published to any repository, but still clearly defines its public interface and should not depend on imports from any other domain libraries or the main app.

Finding out that your Angular components that are about to be extracted from the monolith extend BaseComponent is not a piece of cake. You will unlikely move one to a shared library. Also, there is no good place for such thing as BaseComponent among shared libraries, because over time it tends to turn into a swiss-army knife providing different shared functions for different parts of your app. This may become an impediment slowing down your refactoring initiatives.

Passing constructor arguments every time

When you extend a BaseComponent, you also get to pay the price of calling super() with all of the parameters that the parent's constructor expects:

@Component({...})
export class OrderHistoryComponent extends BaseComponent {
  constructor(private router: Router, 
              private cd: ChangeDetectorRef, 
              @Inject(LOCALE_ID) private localeId: string,
              private userService: UserService,
              private featureFlagService, FeatureFlagService,
              private orderHistoryService: OrderHistoryService) {
    super(router, cd, localeId, userService, featureFlagService);
}
Enter fullscreen mode Exit fullscreen mode

The only provider we actually need in this service is OrderHistoryService. The rest is tax paid to the BaseComponent which we are likely extending for a small reason like getting destroyed$ subject from it. And you are forced to repeatedly pay this tax in every child class, i.e. every component extending BaseComponent. This is both tedious and annoying.

Angular 14 introduced a new way to inject providers - the inject function is now callable during the component's construction phase:

private router = inject(Router);
Enter fullscreen mode Exit fullscreen mode

There are several reasons to like it. Eliminating the problem outlined above is one of them. But it will take time until this will become the standard way of injecting providers in components if it ever will.

Alternatives

I sincerely believe you found the reasons above good enough to get rid of BaseComponent. But our need to share code doesn't go anywhere when we decide to drop one. Also, advice to not do something that is not complemented by what to do instead is not much of advice.

Let's explore the well-known alternative techniques of sharing application logic between multiple components. Depending on your needs you might need one or another, but what they have in common is that each of these options is better than another method in the BaseComponent. These techniques also embrace a very well-known OOP principle.

We are not going to dive very deep into each of these techniques as they deserve separate topics and there are plenty of articles written on these already. But will have a brief intro to each instead and links attached to some of these to explore the topic in more detail.

View providers

A technique I sincerely believe is heavily underestimated in Angular community is view providers. Or the providers you define in the providers property of @Component decorator's metadata.

Say you don't want to repeatedly inject Router and get page settings in the component's initialization phase, and need a way to easily reuse this logic in many components where it is needed.

@Component({    
  ...    
  providers: [PageSettings]
})

private pageSettings = inject(PageSettings); // or provided the old good way in the component's constructor 
Enter fullscreen mode Exit fullscreen mode

This technique is very powerful:

  • You have full access to DI container, just like in the host component.
  • The lifecycle of such a provider is bound to the lifecycle of the host component it is used with. You even have ngOnDestroy hook called in the provider class when the host component is destroyed (but not other component lifecycle hooks).
  • It allows hiding such dependencies behind injection tokens which improves testability a lot. In your TestBed you can replace these dependencies with their mocks implementing the interface of the injected token.

And at the same time, you can create generic component-level providers with shared logic and fine-tune them to specific component needs. This can be achieved by using provider factories. Consider the following example:

{
  provide: PAGE_SETTINGS,
  deps: [SettingsService, ActivatedRoute],
  useFactory: pageSettingsFactory(SettingsStorage.LOCAL)
}
Enter fullscreen mode Exit fullscreen mode

Where the factory would get the necessary page data from the route params and map it to the page settings. The pageSettingsFactory parameter will let the factory know that settings should be read from the local storage. In a different place where this generic provider is used, you might want to read them from the server by passing SettingsStorage.REMOTE. You can read more in this article.

ComponentStore from NgRx library is another good example of this technique. It's small and addresses most of your component's needs for local state management.

Directives of different kinds

Whenever the logic you need to share can be applied in the template, directives are your best friends. Use structural directives when you need to conditionally create or destroy DOM elements, and attribute directives when manipulating properties of the host element is enough:

<div *appRole="'ADMIN'; else: defaultTemplate">Content shown to admins only</div>
Enter fullscreen mode Exit fullscreen mode
<div copyOnClick>This will be copied to the clipboard when clicked on</div>
Enter fullscreen mode Exit fullscreen mode

With the latest host directives API a whole new world of opportunities opens to Angular developers. From simple attribute directives composition to more complex scenarios. You can choose which part of your host directives API to keep private and which to expose behind custom names. Consider this example from the official documentation:

@Component({
  selector: 'admin-menu',
  template: 'admin-menu.html',
  hostDirectives: [{
    directive: MenuBehavior,
    inputs: ['menuId: id'],
    outputs: ['menuClosed: closed'],
  }],
})
export class AdminMenu { }
Enter fullscreen mode Exit fullscreen mode
<admin-menu menuId="top-menu" (menuClosed)="logMenuClosed()">
Enter fullscreen mode Exit fullscreen mode

A component can inject its hostDirectives and vice versa. This makes the interaction between those seamless.

Pipes

A pipe is a very good choice for sharing data transformations in templates. So-called pure pipes are widely used for this purpose. In case simple data transformation is not enough for your needs, just like in directives in pipes you have access to DI, which gives your great power (comes with great responsibility:)).

If you work with Angular, you probably are familiar with these concepts already, so the only thing I would like to add is a handy gotcha on how you can create generic data transformation pipes like this:

<div *ngFor="let item of items | map : pickFirstN : 4">{{item}}</div>
Enter fullscreen mode Exit fullscreen mode

Where instead of pickFirstN your components can provide any other function for their needs that complies with the expected function signature.

Don't overuse pipes though. This is a great tool, but not the right choice for every data manipulation task. Follow the guidelines from Angular documentation:

💡 Use pipes to transform strings, currency amounts, dates, and other data for display.

Decorators

One of the most common things to find in BaseComponent is unsubscription logic. We already highlighted above the downsides of using the extended component's ngOnDestroy hook for this purpose. Let's see how we can solve the same problem with help of Typescript decorators:

@UntilDestroy({ checkProperties: true })
export class MyComponent { 
  subscription$ = interval(2000).pipe(
    tap(() => { /* work done here */ })
  .subscribe();
}
Enter fullscreen mode Exit fullscreen mode

That is it from the user's perspective. The subscription$ will be unsubscribed automatically when MyComponent is destroyed. No need to use takeUntil for unsubscription sake only, no risk of creating memory leaks by not calling parent ngOnDestroy like in the example above.

You can find the code of the UntilDestroy decorator by this link. All it does is patches the component's ngOnDestroy with unsubscription logic and call the original method at the end.

I would warn you about getting over-excited with decorators though. There are a couple of reasons why:

  • The Typescript implementation of decorators differs significantly from the ECMAScript standard decorators proposal that is currently in Stage 3.
  • Decorators are less explicit than alternative sharing techniques. This might be a matter of taste and depends on how much you are into metaprogramming. But in general code explicitness is a good thing, we don't want to have too much magic in our business logic code.

💡 Using decorators for routine work you would prefer the framework to do for you (just like it does for @Component, @Pipe, etc.) is a good rule of thumb.

Resolvers

Not an option for every component. But when the logic to share is loading data for a component bound to a route in your application, a resolver is your friend:

{
  path: 'page/:id',
  component: PageComponent,
  resolve: { settings: PageSettingsResolver }
}

export const pageSettingsResolver: ResolveFn<PageSettings> = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => {
  return inject(PageSettingsService).getPageSettings(route);
};
Enter fullscreen mode Exit fullscreen mode

You can get the resolved data from the data property of ActivatedRoute provider in your component.

Typescript and Javascript at your disposal

It is worth nothing to say that often vanilla JS/TS code-sharing techniques are just enough when you don't need to integrate with the rest of the DI tree. Along with simple functions exported from a file, you can use static methods to group actions that logically belong together.

If for some reason you wanted your own implementation of max and min, there is really no need to create multiple copies of functions that are not using the context of the class they are called from. At the same time, grouping such functions as static methods of the same Math class improves code organization by keeping related things close:

export class CustomMath {
  public static customMin(a: number, b: number): number {
    ...
  }

  public static customMax(a: number, b: number): number {
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

There are also less often used, but still powerful techniques like mixins and proxies.

And of course, a collection of design patterns well tested by time and implementable in Typescript.

Wrapping up

I hope you found the arguments against the BaseComponent reasonable and the mentioned alternatives useful. As you see, Angular offers plenty of powerful tools to make your application DRY.

Thanks for reading and see you at the next one someday!

💖 💪 🙅 🚩
agoncharuks
Alexander Goncharuk

Posted on March 9, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related