Why a Shared BaseComponent Is a Design Mistake in Angular
This article makes the case against using a BaseComponent class in Angular applications as a means of sharing code. As a project matures, the downsides of this approach become increasingly painful.
Although some of the reasoning here may also apply to other modern SPA frameworks, the discussion centers on Angular-specific APIs and practices.
We'll also look at several robust alternatives that Angular provides to keep your codebase DRY.
The Appeal of a BaseComponent
Before we examine the drawbacks, it's worth understanding why BaseComponent classes show up in so many projects.
Angular incorporates several programming paradigms, with OOP being one of them. This naturally leads developers to consider extracting common logic into a parent class that all components can extend. The idea is that this base class would provide all the shared functionality a child component might need. Consider this straightforward illustration:
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
}
Sharing functionality is a universal need in software development, and inheritance is one standard way to achieve it. However, applying inheritance in this manner within Angular is problematic, both because of the inherent issues it creates and because the framework provides far more elegant solutions.
Let's start by dissecting the problems.
The Problems with BaseComponent
It Misapplies the Principle of Inheritance
OOP is undeniably the dominant paradigm in commercial software. Yet, it isn't a one-size-fits-all answer. Inheritance is designed for situations where one object is a more specific type of another. The BaseComponent pattern, however, uses it for a different purpose: sharing a piece of functionality between two unrelated objects.
✅
FullTimeEmployee extends EmployeeandHourlyRateEmployee extends Employeeis a good example of inheritance.❌
FullTimeEmployee extends WithLoggerandHourlyRateEmployee extends WithLoggeris a bad example of inheritance.
This loose interpretation of OOP best practices might stem from engineers applying different standards to the domain model versus the view layer. This distinction, however, is an anti-pattern in itself.
💡 Misusing inheritance is wrong, regardless of the application layer where it occurs.
You Inherit Everything, Whether You Need It or Not
Imagine an application that has a BaseComponent with a few purposes: a destroy$ subject for managing subscriptions, a page-tracking method, and a getter for form validation. You might find your codebase looking like this:
- Components needing to manage subscriptions via
takeUntil(this.destroy$)extendBaseComponentjust for that one piece of logic. - Only a subset of those components actually require the page-tracking feature.
- Form-heavy components will use the
ifFormValidgetter, but others won't. - Conversely, some components that track visits or use forms may not have manual subscriptions and have no need for the
destroy$subject.
By extending BaseComponent, each child class is forced to carry every property and method from the parent, even the ones it will never use. This leads to two clear negative outcomes:
- A performance cost is paid for each component instantiating the entire set of inherited methods.
- The debugging experience is worsened, as components are cluttered with irrelevant code, obscuring their true logic.
The Danger of Overriding Lifecycle Hooks
There's a particular error that keeps recurring across Angular projects:
export class MyComponent extends BaseComponent implements OnInit, OnDestroy {
// ...
ngOnInit(): void {
this.subscription$ = this.service.getSubscription$().pipe(
takeUntil(this.destroyed$)
).subscribe();
}
ngOnDestroy(): void {
this.cleanupMyComponentStuff();
}
}
Can you see the flaw? The takeUntil(this.destroyed$) operator will silently fail because the call to destroyed$.next() resides in the parent's ngOnDestroy, and the child's override forgets to invoke super.ngOnDestroy().
How is it possible for an experienced team to stumble on this? Shouldn't a linter catch it? An eslint rule could be added, but the reality is that accidents happen, leading to subtle memory leaks.
It Tightens Coupling and Hinders Modularity
As an application grows, you might decide some parts are strong candidates for extraction into separate packages or libraries. This could be for a micro-frontend setup, a publishable library for a specific domain, or an internal library in a monorepo meant to have a clear public API.
This becomes a significant challenge if the components you want to extract depend on a monolithic BaseComponent. You can't easily move a component that extends it to a shared library. The BaseComponent itself has no logical home among these libraries; it inevitably evolves into a "swiss-army knife" with a jumble of unrelated shared functionality. This can create a real obstacle to your refactoring and modularization plans.
The Tax of Constructor Arguments
Extending a BaseComponent forces you to call super(), fulfilling all the parent's constructor requirements. This is often boilerplate you're not interested in:
@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);
}
In this example, the component only needs OrderHistoryService. The other injections are an unnecessary burden, paid just to access a simple utility like the destroyed$ subject from the base class. Every single component that extends it must repeat this ritual.
Angular 14 introduced the inject function, which can be called during a component's construction. This offers a fresh alternative:
private router = inject(Router);
There are several benefits to this approach, and solving the constructor boilerplate problem is a key one. However, it may take a while before this becomes the standard way to inject dependencies into components, if it ever does.
Better Alternatives: Composition over Inheritance
The issues above make a strong case for abandoning BaseComponent. But the goal of code sharing still stands. Fortunately, Angular provides several excellent mechanisms to achieve reusability. These alternatives follow the well-established OOP principle of composition over inheritance.
We'll only be touching the surface of each technique here, as each deserves its own in-depth discussion. The goal is to provide a brief overview and pointers for further exploration. The right approach depends on your specific requirements, but each one is a better option than creating a BaseComponent.
1. View Providers: Enhancing Components via DI
View providers—the classes you register in the providers array of the @Component decorator—are an often-overlooked and powerful feature. They allow you to bundle logic that can be consumed by a component's template and class.
For instance, consider encapsulating the logic of loading page settings based on the current route. Instead of reinstating this in each component, you can create a dedicated provider:
@Component({
...
providers: [PageSettings]
})
private pageSettings = inject(PageSettings); // or provided the old good way in the component's constructor
This technique is powerful for several reasons:
- Just like the host component, you have access to the full DI container.
- The provider's lifecycle is tied to the component that declares it. This includes an
ngOnDestroycall on the provider when the host component is destroyed. - Dependencies can be hidden behind injection tokens, which simplifies testing. You can easily swap in mocks in your
TestBedthat adhere to the token's interface.
This pattern also allows for customization. By leveraging provider factories, you can create generic providers and then tailor them for a specific component's needs:
{
provide: PAGE_SETTINGS,
deps: [SettingsService, ActivatedRoute],
useFactory: pageSettingsFactory(SettingsStorage.LOCAL)
}
In this example, the factory retrieves data from the route to build the page settings. We could then have a pageSettingsFactory that reads from local storage instead. Another component might pass SettingsStorage.REMOTE to fetch the settings from a server. This built-in flexibility is demonstrated in more detail in this article.
The ComponentStore from the NgRx library is another instance of this concept, offering a small, focused solution for local component state management.
2. Using Directives for Template Logic
When the functionality you need to share is meant for the template, directives are the best-suited tool. Use a structural directive to add or remove DOM elements conditionally. Use an attribute directive to modify the properties or behavior of an existing element:
<div *appRole="'ADMIN'; else: defaultTemplate">Content shown to admins only</div>
<div copyOnClick>This will be copied to the clipboard when clicked on</div>
The new host directives API is a game-changer for Angular. From simple attribute directive composition to more sophisticated scenarios, you gain immense control. You can even decide how much of the API is internal and what to expose publicly. Here's an example from the official docs:
@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()">
Components and their host directives can readily inject each other, making their collaboration seamless.
3. Pipes for Data Transformations
Pipes are ideal for transforming data directly in your templates. The standard pure pipes serve this purpose perfectly for most cases. When you need more complex behavior, remember that, similar to directives, pipes can leverage Angular's DI system to access services, unlocking significant flexibility.
You likely already know the fundamentals. A useful extra technique is creating a factory function for generic pipes, which allows your components to provide their own transformation logic:
<div *ngFor="let item of items | map : pickFirstN : 4">{{item}}</div>
Here, instead of pickFirstN, your components can pass any other compatible function to tailor the pipe's output.
However, avoid overusing pipes. They're a fantastic tool, but not a universal solution for data manipulation. Stick to the official guidelines:
💡 Use pipes to transform strings, currency amounts, dates, and other data for display.
4. Using Decorators to Manage Unsubscription
Consider the common problem of unsubscribing from RxJS observables. The pitfalls of overriding ngOnDestroy are clear, but we can solve this elegantly with a TypeScript decorator:
@UntilDestroy({ checkProperties: true })
export class MyComponent {
subscription$ = interval(2000).pipe(
tap(() => { /* work done here */ })
.subscribe();
}
This is the whole interface a developer needs. The subscription$ is automatically cleaned up when MyComponent is destroyed, eliminating the need for explicit takeUntil logic and completely avoiding the memory leak risk from missing super.ngOnDestroy() calls.
You can find a full implementation of the UntilDestroy decorator at this link. Its inner workings patch the component's ngOnDestroy method to perform the unsubscription and then call the original method.
While powerful, using decorators warrants some caution:
- The TypeScript decorator implementation is not the same as the ECMAScript standard proposal, which is still at Stage 3.
- Decorators are often less explicit than other sharing techniques. This may come down to personal preference, but relying on too much metaprogramming can make business logic harder to understand.
💡 It's a good rule to follow to use decorators for the routine work you'd rather the framework handle (like it does with
@Component,@Pipeetc.).
5. Applying Resolvers for Route Data
This isn't applicable to every component, but when the shared logic involves pre-loading data for a routed component, a resolver is the perfect fit:
{
path: 'page/:id',
component: PageComponent,
resolve: { settings: PageSettingsResolver }
}
export const pageSettingsResolver: ResolveFn<PageSettings> = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => {
return inject(PageSettingsService).getPageSettings(route);
};
The resolved data is made available to the component via the data property of the ActivatedRoute provider.
6. The Power of Plain Typescript
When your shared logic doesn't need to be part of Angular's DI system, standard TypeScript techniques are often more than sufficient. Simple exported functions work great. You can also group related, context-free functions as static methods within a class to improve code organization:
export class CustomMath {
public static customMin(a: number, b: number): number {
...
}
public static customMax(a: number, b: number): number {
...
}
}
There are also more advanced but less common patterns like mixins and proxies. And don't forget the wealth of design patterns that have been proven over time and are directly implementable in TypeScript.
Key Takeaway
The case against BaseComponent is strong, and Angular is replete with superior alternatives. These tools demonstrate the framework's commitment to the principle of composition over inheritance. They allow you to build a clean, modular, and maintainable application.
