You don't want a BaseComponent in your app
Alexander Goncharuk
Posted on March 9, 2023
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
}
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
andHourlyRateEmployee extends Employee
is a good example of inheritance.❌
FullTimeEmployee extends WithLogger
andHourlyRateEmployee 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 theBaseComponent
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();
}
}
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);
}
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);
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
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)
}
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>
<div copyOnClick>This will be copied to the clipboard when clicked on</div>
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 { }
<admin-menu menuId="top-menu" (menuClosed)="logMenuClosed()">
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>
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();
}
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);
};
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 {
...
}
}
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!
Posted on March 9, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.