Overlooked directive selectors
At some point, nearly every Angular developer has written a custom directive. In most cases, the selector is simply an attribute-based one. However, Angular actually supports more sophisticated selectors, including class-based and element-based ones.
Why does this matter?
Let's walk through a practical example. Suppose your application enforces a rule that long text gets truncated with an ellipsis (…) at the end. To handle this, you created a .truncate CSS class that can be applied to any element:
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
This approach works well, and now your templates are full of elements that look like this:
<p class="truncate">This is a long text that will be truncated</p>
But then a new requirement arrives: truncated text needs a tooltip so users can see the full content on hover. You already have a TooltipDirective for this purpose. At this point, you could go through hundreds of files and manually add the directive to each element — but that hardly sounds appealing.
Fortunately, there's a cleaner solution. You can write a directive that binds to the .truncate class and applies the TooltipDirective as a host directive. Here's how:
@Directive({
selector: '.truncate',
hostDirectives: [TooltipDirective],
host: {
'(mouseenter)': 'showTooltip()',
'(mouseleave)': 'hideTooltip()'
}
})
export class TruncateDirective {
readonly #elRef = inject(ElementRef);
readonly #tooltipRef = inject(TooltipDirective);
showTooltip() {
this.#tooltipRef.show(this.#elRef.nativeElement.textContent);
}
hideTooltip() {
this.#tooltipRef.hide();
}
}
From now on, simply importing the directive into any relevant component automatically gives tooltip functionality to every element with the .truncate class.
The :not() selector is another powerful option. This becomes particularly handy when you want to add a boolean input that disables a directive. For example, in your directive, there might be situations where a tooltip simply doesn't fit the UI, so you could add a noTooltip input:
@Directive({
selector: '.truncate',
hostDirectives: [TooltipDirective],
host: {
'(mouseenter)': 'showTooltip()',
'(mouseleave)': 'hideTooltip()'
}
})
export class TruncateDirective {
noTooltip = input(false);
readonly #elRef = inject(ElementRef);
readonly #tooltipRef = inject(TooltipDirective);
showTooltip() {
if (!this.noTooltip) {
this.#tooltipRef.show(this.#elRef.nativeElement.textContent);
}
}
hideTooltip() {
this.#tooltipRef.hide();
}
}
However, this approach complicates the directive's internal logic, and the directive — along with its host directive — still gets applied to every element with the .truncate class. A better option is to prevent the directive from being applied at all by excluding elements that carry a specific attribute. Here's how:
@Directive({
selector: '.truncate:not([noTooltip])',
hostDirectives: [TooltipDirective],
host: {
'(mouseenter)': 'showTooltip()',
'(mouseleave)': 'hideTooltip()'
}
})
export class TruncateDirective {
@Input() noTooltip = false;
readonly #elRef = inject(ElementRef);
readonly #tooltipRef = inject(TooltipDirective);
showTooltip() {
this.#tooltipRef.show(this.#elRef.nativeElement.textContent);
}
hideTooltip() {
this.#tooltipRef.hide();
}
}
Then you can use the directive like this:
<p class="truncate">
This is a long text that will be truncated with a tooltip
</p>
<p class="truncate" noTooltip>
This is a long text that will be truncated without a tooltip
</p>
As a side note, directives are remarkably capable. If you haven't already, I'd recommend reading my comprehensive article on directives here.
Now, let's dive into a truly obscure feature.
Reading a service from a view child
View children aren't used every day, but almost every Angular developer has worked with them at some point. They're typically used to access child components, directives, or template elements. With more advanced knowledge, you might also know that you can read a view child's native element or its view container reference (via the {read: ViewContainerRef} or {read: ElementRef} options). But here's the fascinating part: you can also read an instance of a service that's provided on a view child.
There are two ways to achieve this. The first is to specify which component provides the service and read it from that particular view child:
@Component({
selector: 'app-parent',
template: `
<app-child #child></app-child>
`
})
export class ParentComponent {
myService = viewChild(ChildComponent, { read: MyService });
someMethod() {
// Now we can call the service method
this.myService().doSomething();
}
}
Note that we call
myServicebecauseviewChildreturns a signal — in this case, a signal of the requested service.
The second approach is to read the service from any view child, without knowing which component provides it. You can do this by simply querying the service directly:
@Component({
selector: 'app-parent',
template: `
<app-child #child></app-child>
`
})
export class ParentComponent {
myService = viewChild(MyService);
someMethod() {
// Now we can call the service method
this.myService().doSomething();
}
}
With this method, though, you can't be certain which instance you'll receive, since multiple child components in the view might provide the same service. The query returns the first instance it finds.
Where does this come in handy?
While this feature might seem exotic at first, there are genuinely useful scenarios. Imagine you have a store service holding data related to specific child components — for instance, different task types on columns of a task board. Each child component provides its own version of the store, ensuring its data stays fully isolated from neighboring columns.
Warning: generally speaking, this approach is an anti-pattern because it breaks the usual data flow (directly mutating state in a child component could trigger UI changes in the parent). It should only be used when you don't own the child components — for instance, when they come from a third-party library or another team you have no control over. In most cases, relying on inputs/outputs or a centralized store is preferable. Use this technique with caution, and only when it's absolutely necessary.
In the parent component, however, you might want to display the total number of tasks. With viewChildren and computed signals, this becomes straightforward:
@Component({
selector: 'app-parent',
template: `
<h2>Total tasks: {{ total() }}</h2>
<app-task-column type="todo"/>
<app-task-column type="inProgress"/>
<app-task-column type="done"/>
`
})
export class TaskBoard {
taskStores = viewChildren(TaskStore); // query all instances of the task store in the view
// get the total of each task store and add them to get the total number of tasks
total = computed(() => this.taskStores().reduce((acc, store) => acc + store.tasks().length, 0));
}
This is a valuable pattern to remember when dealing with complex scenarios where you want to avoid modifying child components and instead push as much logic as possible into the parent. Next, let's examine a feature that's slightly more known, yet still underutilized.
We've all used content projection in Angular, and some of us have even built our own components that rely on ng-content. But what many developers overlook is the ability to precisely control where and what content gets projected. By using named slots, you can reduce component complexity and potentially cut down on the number of configuration inputs.
Imagine you're building a custom rich text editor. It's used in many places throughout the application, each with a different feature set. For example, your component might support copy-paste buttons, undo-redo buttons, and text formatting controls (bold, italics, etc.). You diligently created separate components for each of those buttons, and your component now looks like this:
@Component({
selector: 'app-rich-text-editor',
template: `
<div class="editor">
@if (undoRedo()) {
<app-undo-redo-buttons/>
}
@if (copyPaste()) {
<app-copy-paste-buttons/>
}
@if (textFormatting()) {
<app-text-formatting-buttons/>
}
<textarea></textarea>
</div>
`
})
export class RichTextEditor {
undoRedo = input(false);
copyPaste = input(false);
textFormatting = input(false);
}
This setup allows you to customize each usage location, providing a decent developer experience. However, there are a few downsides to this approach:
- The number of component inputs will only grow as you add more editor features. Consider this:
<app-rich-text-editor
[undoRedo]="true"
[copyPaste]="true"
[textFormatting]="true"
[insertImage]="true"
[insertLink]="true"
[insertTable]="true"
/>
This doesn't look very appealing.
2. The component's internal code will be littered with @if statements, which doesn't exactly improve readability.
3. Tree-shaking suffers: users might only visit a page showing the simplest version of the editor, but the code for every other feature still gets bundled in.
Instead, you can use named slots to project content exactly where you need it. Here's how:
@Component({
selector: 'app-rich-text-editor',
template: `
<div class="editor">
<ng-content select="[app-undo-redo-buttons]"/>
<ng-content select="[app-copy-paste-buttons]"/>
<ng-content select="[app-text-formatting-buttons]"/>
<textarea></textarea>
</div>
`
})
export class RichTextEditor {}
Notice how we eliminated all the @if statements and every input for the rich text editor. Now, you can use the component like this:
<app-rich-text-editor>
<app-undo-redo-buttons/>
<app-copy-paste-buttons/>
<app-text-formatting-buttons/>
</app-rich-text-editor>
What's the benefit?
As you can see, there's no need to declare a pile of inputs to use the component — you can simply drop in the buttons you require. The component's code is also cleaner, and named slots guarantee that a) components are inserted in the correct positions and b) no stray custom templates can be placed into the editor template. Moreover, tree-shaking improves dramatically: each component using the rich text editor decides for itself which nested components get included in the bundle.
Now, let's explore a feature that has a narrower scope but can be extremely helpful in certain situations.
Sticking with the rich text editor example, suppose you're integrating a third-party editor. Such editors typically export custom styles along with the HTML the user typed and formatted. You naturally want to render that HTML elsewhere in your application using innerHTML:
@Component({
selector: 'app-custom-text',
template: `
<div [innerHTML]="html"></div>
`
})
export class CustomText {
readonly #sanitizer = inject(DomSanitizer);
rawHtml = input.required<string>();
// here we use the sanitizer to inform Angular the HTML is safe
// be very careful with this, as it can lead to XSS attacks
// if you are unsure about the input, or have not sanitized it in any way
// DO NOT use it directly!
html = computed(() => this.#sanitizer.bypassSecurityTrustHtml(this.rawHtml()));
}
This works, but it can lead to an amusing problem: the editor's styles leak into the whole application, not just the app-custom-text component. The reason is that these styles aren't encapsulated — they get applied globally.
We've all heard about CSS encapsulation by now. In essence, Angular components isolate their views from one another, so styles applied to .my-class in component A won't affect a <div class="my-class"> in component B's template. This is known as viewEncapsulation. By default, Angular uses Emulated encapsulation, which mimics the shadow DOM by adding random attributes to component templates, thus preventing style collisions. This approach, however, leaves a gap: styles defined in the global styles.css file will affect the entire application.
The same vulnerability applies to innerHTML, as injected styles bypass the random attribute mechanism and become global. To prevent this, you can switch the component to true encapsulation by using the ShadowDOM option:
@Component({
selector: 'app-custom-text',
template: `
<div [innerHTML]="html"></div>
`,
encapsulation: ViewEncapsulation.ShadowDom
})
export class CustomText {
readonly #sanitizer = inject(DomSanitizer);
rawHtml = input.required<string>();
html = computed(() => this.#sanitizer.bypassSecurityTrustHtml(this.rawHtml()));
}
With this in place, anything a user formats in their custom text won't alter the rest of the application's appearance. You can read more about the Shadow DOM here.
To illustrate this feature, consider a scenario where a colleague built a reusable component that takes a user ID and renders a UserProfileComponent. There's a catch though: your application serves two kinds of users — internal users and third-party users with limited access. You have two services, UserService and ThirdPartyUserService, both provided at the application root, each working with the appropriate APIs to fetch the correct data.
The problem is that UserProfileComponent only knows about UserService — it directly injects this service in its constructor. You could modify the component to accept a service via input, or try other workarounds, but that might introduce breaking changes and extra verbosity, which you'd rather avoid.
Luckily, both services implement the same interface, sharing identical methods with only the API endpoints differing. This allows you to take advantage of one of Angular's underappreciated features — the useExisting provider option — so that pages dealing with third-party users transparently make UserProfileComponent work with ThirdPartyUserService:
@Component({
selector: 'third-party-component'
template: `
<app-user-profile [userId]="userId"/>
`,
providers: [
{
provide: UserService,
useExisting: ThirdPartyUserService
}
]
})
export class ThirdPartyComponent {
userId = input.required<string>();
}
So, what's happening here? When UserProfileComponent injects UserService anywhere else, it receives the root-level UserService instance. But within your component, this useExisting provider intercepts the injection and supplies the ThirdPartyUserService instance instead (again from the root — you're reusing the existing ThirdPartyUserService, not creating a new one). This ensures UserProfileComponent works with the correct API here, without spawning new instances of the service that could break shared data between components relying on it.
NgZone.runOutsideAngular
Suppose we're building a "Scroll to top" button for our app. The goal is a reusable component that subscribes to the user's scroll events and toggles the button's visibility based on the scroll position. A straightforward implementation might look like this:
@Component({
selector: 'app-scroll-to-top',
template: `
@if (showButton()) {
<button (click)="scrollToTop()" >Scroll to top</button>
}
`
})
export class ScrollToTop {
showButton = toSignal(
fromEvent(window, 'scroll').pipe(
map(() => window.scrollY > 100)
),
);
}
This approach functions correctly, but it carries a significant performance drawback. In fact, it fires off an excessive number of change detection cycles. To verify this, we can attach a method to the template that logs a message every time change detection runs:
@Component({
selector: 'app-scroll-to-top',
template: `
@if (showButton()) {
<button (click)="scrollToTop()" >Scroll to top</button>
}
{{ logChangeDetection() }}
`
})
export class ScrollToTop {
showButton = toSignal(
fromEvent(window, 'scroll').pipe(
map(() => window.scrollY > 100)
),
);
logChangeDetection() {
console.log('Change detection triggered');
}
}
It becomes quickly apparent that the volume of inert CD cycles is simply not acceptable. So why does this happen? In essence, Angular leverages the Zone.js library to keep tabs on asynchronous event listeners. Every time one of these events fires, change detection is triggered to determine whether the async operation altered any template bindings, and if so, the view gets updated. This mechanism is undeniably handy, but it's all too easy to set off a cascade of CD cycles unintentionally—exactly what we're witnessing here.
In this particular component, we're listening to the scroll event, which naturally fires constantly, generating a stream of pointless CD cycles. How can we mitigate this? The solution lies in the NgZone injectable. By using it, we can signal to Zone.js that this specific event listener should not be tracked:
@Component({
selector: 'app-scroll-to-top',
template: `
@if (showButton()) {
<button (click)="scrollToTop()" >Scroll to top</button>
}
{{ logChangeDetection() }}
`
})
export class ScrollToTop {
showButton = signal(false);
constructor(private readonly #zone: NgZone) {
this.#zone.runOutsideAngular(() => {
fromEvent(window, 'scroll').subscribe(() => {
this.showButton.set(window.scrollY > 100);
});
});
}
logChangeDetection() {
console.log('Change detection triggered');
}
}
After this adjustment, if we look at the console again, we'll see a dramatic drop in the number of CD cycles. This improvement comes from instructing Zone.js to disregard the scroll listener. Instead, we now depend on the showButton signal itself to drive change detection. Updating a signal always prompts change detection, but we're protected from an overload of cycles because CD only kicks in when the showButton signal's value actually changes (for instance, reassigning false when it's already false won't invoke CD). As demonstrated, this is a straightforward technique to give our app's performance a meaningful lift.
Note: down the line, Angular applications will move to a Zoneless architecture (meaning they won't depend on Zone.js for change detection). However, given that the vast majority of Angular apps still rely on Zone.js as of 2025, it's perfectly fine to leverage
NgZone.runOutsideAngularto optimize performance now. Still, steer clear of otherNgZonemethods such asonStableoronMicrotaskEmpty, as those might behave differently once the application transitions to a Zoneless model.
Wrapping Up
Angular is packed with features, and many of them don't get the attention they deserve. It's impossible to cover everything in a single post, but my hope is that this article helps you uncover a few of those more obscure tools that can turn out to be incredibly valuable in certain situations.
A Quick Plug

My book, Modern Angular, has officially hit the shelves! I put a lot of effort into documenting every new Angular feature from v12 to v18—covering topics like enhanced dependency injection, RxJS interop, Signals, SSR, Zoneless, and much more.
If you're maintaining a legacy project, I think my book will help you get up to speed with all the fresh and exciting developments our favorite framework offers. You can find it at: https://www.manning.com/books/modern-angular


