Angular is a very, very powerful framework, which means it has lots of features. Of course, everyone knows the essentials, like components, services, routing, etc. But obviously, almost every Angular developer has some features they've never tried, or even heard of. In today's article, we are going to cover some of the features that are relatively obscure, but can supercharge our applications. Let's get started!

Using more complex directive selectors

Every Angular developer has, at some point, authored a custom directive. In my experience, in 95% of the cases, the directive selector is just a simple attribute selector. But Angular actually allows us to use more complex selectors, like class selectors, element selectors, and so on.

How can this be useful?

Let's cover one example to see how this can be useful. Let's say in our application we have a rule that long texts get truncated, with a trailing ellipsis (...) at the end. For this purpose, we created a .truncate CSS class that we can just drop on any element to make it truncated:

.truncate {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

This works fine, and now we have thousands of HTML elements in our templates that look like this:

<p class="truncate">This is a long text that will be truncated</p>

However, a new requirement just dropped, and now we need to add a tooltip to the truncated text, so that when the user hovers over the element, they can see the full content. Let's assume we already have a TooltipDirective that we can use to add tooltips to elements. So, now, what is left to do is go through hundreds of files and add the directive to each element... wait, this doesn't sound very exciting.

Thankfully, there is a solution to this! We can write a directive that will bind to the .truncate class and add the TooltipDirective to the element as a Host Directive. Here is how we can do this:

@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();
    }
}

Now, we can simply import the directive in any relevant component and all elements with the .truncate class will automatically have the tooltip functionality!

Another great use case is utilizing the :not() selector. This can come handy when we add some boolean input to prevent a directive from executing. For example, in this directive, we might have scenarios where the UI is not suitable to display the tooltip, so we can add a noTooltip input to the directive:

@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 complicates the logic of this directive, and it still gets applied to any elements with class .truncate (and the TooltipDirective as a host directive with it). But thankfully, we can just prevent the directive being applied at all by excluding elements with a specific attribute. Here is how we can do this:

@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, we can just 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 actually extremely powerful, and if you haven't, I'd suggest reading my mega-article on directives here.

Now, let's move to a really obscure feature.

Reading a service from a view child

While using view children is not very common, every Angular developer has done it at least once in their career. As we know, view children are used to access child components, directives, or elements in the template. If our knowledge is a bit more advanced, we might also know that we can read the native element of a view child, or its view container reference (using the {read: ViewContainerRef} option or the {read: ElementRef} option). However, what is truly fascinating, we can also read an instance of a service provided on a view child!

Now, there are to ways of achieving it. One is to specify which component provides the service, and read it from that specific 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 the myService, as viewChild return a signal, so, in this case, it will be a signal of the service we requested.

The second way is to read the service from any view child, regardless of which component provides it. This can be done by actually just 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();
  }
}

In this case, however, we can't be sure which instance of the service we are getting, as the view might contain several child components that provide this service. This query will retrieve the first instance.

How can this be useful?

While the feature itself might be quite exotic, some useful cases can be found for it. Imagine we have a store service that contains data related to some child components, for example, different types of tasks on some columns on a task board. Each child component provides its own version of the store so that its data is fully separated form neighboring columns.

Warning: this approach is, in general, an anti-pattern, as it is breaking the usual data flow approach (directly modifying the state in the child component might result in UI changes in the parent), and should be used only in scenarios where we do not own the child components (for instance, they come from a third-party library or from another team over which we do not have control). In general, it is better to rely on inputs/outputs or a centralized data store. Use this with caution only when absolutely necessary

However, in the parent component, we wish to display the total number of tasks. We can achieve this easily with viewChildren and computed signals:

@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 worth remembering when dealing with complex scenarios where we don't want to modify the child components and instead want to try to move all the logic to the parent component. Now, let us take a look at a relatively more known, but still underutilized feature.

Content projection with named slots

We all used some content projection in Angular, and might event implemented our own components that use ng-content. However, what many developers don't use quite often is the ability to control where and what content should be projected. This can be done by using named slots, and might helps us decrease the complexity of our components and also maybe reduce the number of configuration inputs it receives.

Imagine we are building our own custom rich text editor. It is going to be used in a lot of places across the application, and almost every time it supports a different set of features. For example, our component might support copy-paste buttons. undo-redo-buttons, and text formatting buttons (like bold text, italics, etc). We diligently created separate components for all of those buttons and now our component 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);
}

No with this, we can customize each place where the rich text editor is used, getting a pretty decent developer experience. However, this approach has a couple of downsides.

  1. The number of component inputs will only grow as we add more features to the editor. Take a look at this:
<app-rich-text-editor 
  [undoRedo]="true" 
  [copyPaste]="true" 
  [textFormatting]="true"
  [insertImage]="true"
  [insertLink]="true"
  [insertTable]="true"
/>

This just doesn't look very nice
2. The component code itself will be riddled with @if statements, which isn't exactly amazing for readability
3. Deteriorated tree-shaking: for example, the users might only visit a page where they can see only the simplest version of the text editor, but all the code for the other features will still be included in the bundle.

So, instead, we can use named slots to project the content where we want it. Here is how we can do it:

@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 {}

As we can see, we removed all the @if statements and all the inputs for the rich text editor. Now, we 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>

How is this useful?

Now, as we can see, we do not have to write a bunch of inputs to use the component, we can just drop the buttons we need in the editor. The component code itself has also improved, while using named slots ensured that a) the components are inserted at their correct place and b) no other custom templates can be inserted into the editor template. In addition to it, we have great tree-shaking, as each component that uses the rich text editor will itself decide which nested components will be included in its bundle.

Now, next let us see a feature that has a bit of a limited scope, but can be extremely useful in some scenarios.

Using Shadow DOM

For this feature, we can continue the previous example with the rich text editor. If we are using some third-party editor, it usually will export some custom styles HTML based on what the user has typed and formatted in their text. Then, we would want to display that HTML in some other place in our 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, however, it can result in a funny problem: the styles from the rich text editor will be applied to the whole application, not just the app-custom-text component. This is because the styles are not encapsulated in the component, and are applied globally.

At some point all of us heard about CSS encapsulation, and in general we think about it this way: Angular components isolate their views from other components, so if component A has some styles applied to the CSS class .my-class, they will not affect a <div class="my-class"> in component B template. This is commonly know as viewEncapsulation. Byt default, Angular uses Emulated view encapsulation, which means it will emulate the shadow DOM, adding some randomly generated attributes to component templates to ensure styles from different components do not clash. This, however, opens the pathway to modify CSS from the global scope - this is why CSS styles written in the styles.css file will be applied to the whole application.

The same issue happens with the innerHTML property, as it will apply the styles globally, without the randomly generated attributes. To avoid this, we can make the component's style truly encapsulated 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()));
}

This way, we can be sure anything the user formats in their custom text won't affect hot the rest of the application looks. Read more about the Shadow DOM here.

Reusing existing providers

To better understand this feature, let's imagine a scenario: a developer before us has authored a reusable component that receives a user ID and renders a UserProfileComponent. But there is a catch - our app has two types of users, our own users and also third-party users with some limited access to features. For this purpose, we have two services, UserService and ThirdPartyUserService, both provided at the application root, that work with corresponding APIs to ensure we get the correct data.

However, the UserProfileComponent only works with the UserService, as it directly injects UserService in its constructor. We could modify the component to accept a service as an input, or maybe perform some other trickery, but this might result in other breaking changes and increased verbosity, which we would love to avoid.

Thankfully, the UserService and ThirdPartyUserService both implement the same interface, having the same methods with just the API endpoints themselves being different. This means, we can utilize an underrated feature of Angular - the useExisting provider option, to ensure the pages that work with third party users make the UserProfileComponent work with the ThirdPartyUserService under the hood:

@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 happens here? When the UserProfileComponent injects the UserService, in other cases, it will get the instance of UserService from the root, but in our component, it will stumble upon this useExisting provider which will give it the instance of ThirdPartyUserService (again from the root - we are reusing the ThirdPartyUserService, not creating a new instance). This ensures that the UserProfileComponent works with the correct API here, but that also there won't be any new instances of that API service, potentially breaking the shared data between the components that use it.

NgZone.runOutsideAngular

Imagine we are implementing a "Scroll to top" button in our application. We want a reusable component that would listen to user's scroll events and show/hide the button based on the scroll position. We could implement it 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)
    ),
  );
}

And this will work properly, however, there is a major issue with this implementation in terms of performance. Actually, it triggers a lot of change detection cycles. We can easily check this by adding a method to the template that will log something on each change detection cycle:

@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');
  }
}

We'll quickly see that the number of unnecessary CD cycles is simple unacceptable. So, why is this happening? In short, Angular uses the Zone.js library to monitor asynchronous event listeners and trigger change detection every time such an even happens to check if these async operations resulted in any changes to template bindings, and if so, update the view. While this is obviously useful, it's very easy to accidentally trigger an avalanche of CD cycles, as we have seen here.

With this component, we subscribe to the scroll event, which obviously happens a lot, triggering unnecessary CD cycles. So, what can we do about it? We can utilize the NgZone injectable to inform Zone.js not to track this particular event listener:

@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');
  }
}

Now, if we re-check the console output, we'll see that the number of CD cycles has decreased significantly. This is because we are now telling Zone.js to ignore the scroll event listener, and instead rely on the showButton signal itself to perform change detection. Signal updates always trigger change detection, but we are safe from having too many cycles because CD will only be triggered when the showButton signal actually changes its value (so setting false on it while it's already false won't trigger CD). As we can see, this is a very easy way to significantly boost our application's performance.

Note: in the future, Angular application will be Zoneless (i.e. not rely on Zone.js for change detection). However, considering the overwhelming majority of existing Angular applications use Zone.js as of 2025, feel free to use NgZone.runOutsideAngular to improve performance in your applications, but try to avoid other NgZone methods like onStable, onMicrotaskEmpty, etc. as they might not work as expected when the app turns Zoneless in the future.

Conclusion

Angular has a lot of features, quite a lot of which are not very well-known; I cannot hope to cover them all in one article. However, with this one, I hope to help developers discover some of the more obscure features that can be extremely useful in some scenarios.

Small Promotion

Gg2RPJKWwAAHSId.png
My book, Modern Angular, is now in print! I spent a lot of time writing about every single new Angular feature from v12-v18, including enhanced dependency injection, RxJS interop, Signals, SSR, Zoneless, and way more.

If you work with a legacy project, I believe my book will be useful to you in catching up with everything new and exciting that our favorite framework has to offer. Check it out here: https://www.manning.com/books/modern-angular



Tagged in:

Articles

Last Update: March 04, 2025