Why composing small, reusable directives used to break in surprising ways — and how the CDK's dynamic component rendering just got a lot more capable
Ever built two small, focused host directives, only to slap them both on the same element and get a duplicate directive error that made no sense at first glance? If you've spent time with Angular's Directive Composition API, there's a good chance you've hit exactly this wall — two directives that each quietly pull in the same shared behavior, and Angular refusing to let them coexist.
Angular 22 fixes that, and pairs it with a second, less talked-about improvement: the CDK's ComponentPortal can now attach directives to a component it renders dynamically. Both changes are aimed at the same underlying problem — composing behavior without duplicating it or baking it into components that shouldn't need to know about it. This one's a bit more advanced than the usual "here's a new signal function" post, so it'll appeal most if you write shared directives, build component libraries, or lean on the CDK's Portal API for dynamic rendering. By the end, you'll know:
- Why two directives that both use
hostDirectivesto pull in the same shared directive used to conflict - How Angular 22's de-duplication resolves that, and the one rule you still have to follow
- How
ComponentPortalcan now apply directives to a dynamically rendered component's host element - How the same directive can travel with a component across both
CdkPortalOutletandDomPortalOutlet - How to unit test both patterns
If you've been following the rest of this Angular 22 series — resources, the router, templates, the AI tooling — this is the entry aimed squarely at library authors and anyone building serious component composition. If none of that rings a bell yet, it's worth a look too; these patterns show up the moment your app grows past a handful of components.
One thing before we dig in: this is the kind of topic that doesn't get nearly enough coverage, so if it's useful, following me here means you catch the next one of these instead of scrolling past it later.
Before we dive into the examples, a quick note: the code snippets provided here are meant purely for understanding the concept. Some syntax shown may reflect patterns from earlier Angular versions. Always refer to the official documentation for the most current API and syntax.
The problem: shared behavior, duplicated by accident
Angular's Directive Composition API lets a directive pull in another directive's behavior through hostDirectives, which is a great way to keep each directive small and focused instead of writing one directive that does everything. The trouble starts when two different higher-level directives both happen to reuse the same lower-level one — a pattern that's common the moment you're building a small internal library rather than a one-off feature.
Say you've got a shared directive that manages a transient, accessible status message on whatever element it's attached to:
import { computed, DestroyRef, Directive, inject, input, signal } from '@angular/core';
@Directive({
selector: '[appStatusMessage]',
host: {
'aria-live': 'polite',
'[attr.data-status]': 'visibleMessage()',
'[class.show-status]': 'hasMessage()',
},
})
export class StatusMessageDirective {
readonly idleLabel = input('');
private readonly temporaryMessage = signal<string | null>(null);
private timeoutId: ReturnType<typeof setTimeout> | null = null;
private readonly destroyRef = inject(DestroyRef);
constructor() {
this.destroyRef.onDestroy(() => this.clear());
}
protected readonly hasMessage = computed(() => this.temporaryMessage() !== null);
protected readonly visibleMessage = computed(
() => this.temporaryMessage() ?? this.idleLabel(),
);
announce(message: string, duration = 2000): void {
this.clear();
this.temporaryMessage.set(message);
this.timeoutId = setTimeout(() => this.temporaryMessage.set(null), duration);
}
private clear(): void {
if (this.timeoutId !== null) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
}
}
Now build two independent, higher-level directives on top of it — one that toggles a favorite state, one that copies a share link — and have each of them compose StatusMessageDirective to announce what just happened.
import { Directive, inject, input, signal } from '@angular/core';
import { StatusMessageDirective } from './status-message.directive';
@Directive({
selector: '[appFavoriteToggle]',
hostDirectives: [{ directive: StatusMessageDirective, inputs: ['idleLabel: status'] }],
host: {
'(click)': 'toggle()',
'[class.is-favorited]': 'favorited()',
},
})
export class FavoriteToggleDirective {
readonly itemId = input.required<string>();
protected readonly favorited = signal(false);
private readonly status = inject(StatusMessageDirective);
protected toggle(): void {
this.favorited.update((current) => !current);
this.status.announce(
this.favorited() ? 'Added to favorites' : 'Removed from favorites',
);
}
}
import { Directive, inject, input } from '@angular/core';
import { StatusMessageDirective } from './status-message.directive';
@Directive({
selector: '[appShareLink]',
hostDirectives: [{ directive: StatusMessageDirective, inputs: ['idleLabel: status'] }],
host: {
'(click)': 'share()',
},
})
export class ShareLinkDirective {
readonly url = input.required<string>();
private readonly status = inject(StatusMessageDirective);
protected async share(): Promise<void> {
try {
await navigator.clipboard.writeText(this.url());
this.status.announce('Link copied');
} catch {
this.status.announce('Unable to copy link');
}
}
}
Both directives are small, focused, and independently useful. Naturally, you'll want to apply both to the same action bar so a card's footer can both favorite and share, sharing one status announcement region:
<div
appFavoriteToggle
[itemId]="item.id"
appShareLink
[url]="item.shareUrl"
status="Card actions"
class="card-actions"
>
<button aria-label="Favorite">Favorite</button>
<button aria-label="Share">Share</button>
</div>
Before Angular 22, this setup throws a duplicate directive error. Angular sees appFavoriteToggle bringing in StatusMessageDirective, sees appShareLink bringing in the very same directive, and concludes — incorrectly, from your point of view — that the same directive is being matched twice on one element. You weren't trying to create two separate status regions; you were composing two independent behaviors that happen to share one lower-level dependency, which is exactly the kind of thing you'd expect the Directive Composition API to support cleanly.
Angular 22 de-duplicates shared host directives
Angular 22 fixes this directly: when the same directive shows up more than once in the resolved host directive tree, Angular merges those matches into a single shared instance instead of treating them as a conflict. The two action-bar directives above now work together without any code changes on your part — the composition just works the way you'd expect.
There's one rule worth keeping in mind, though. Angular still has to reconcile the input and output aliases from every place the shared directive is composed, so if two directives expose the same underlying input under two different aliases, you're back to an error — this time NG8024, conflicting host directive binding. In the example above, both FavoriteToggleDirective and ShareLinkDirective alias idleLabel to status, which is exactly why they merge cleanly. If one of them had aliased it to something else instead, Angular wouldn't be able to decide which public name should win.
So the practical rule is simple: if multiple directives in your library reuse the same shared host directive, keep their aliases for that directive's inputs and outputs consistent across the board.
Have you run into this exact duplicate-directive error before finding out why it was happening? I'd genuinely like to know whether people worked around it by merging directives into one, or just avoided composing them on the same element entirely.
Dynamic component rendering gets more capable: directives on ComponentPortal
The CDK's Portal API is Angular's lower-level tool for dynamic component rendering — instead of declaring a component in a template, you describe it as a ComponentPortal and hand it to a PortalOutlet to actually create and attach. Until this release, a component rendered this way had no clean way to receive host-level behavior specific to where it was being rendered — you either baked that behavior into the component itself, which coupled it to one particular context, or you left it out.
Say you have a small NotificationPanel component that needs to behave like an accessible live region — role="status", aria-live="polite", and focus when it appears — but you render it in two different places: inline inside a page, and as a floating panel attached directly to the document body.
A directive is the right home for that host-level behavior, since it keeps NotificationPanel itself free of any assumptions about where it's rendered:
import { afterNextRender, Directive, ElementRef, inject, input } from '@angular/core';
@Directive({
selector: '[appLiveRegion]',
host: {
role: 'status',
'aria-live': 'polite',
'[attr.aria-label]': 'label()',
tabindex: '-1',
},
})
export class LiveRegionDirective {
readonly label = input('');
private readonly elementRef = inject(ElementRef);
constructor() {
afterNextRender(() => this.elementRef.nativeElement.focus());
}
}
Angular 22 lets ComponentPortal apply directives like this one directly to the component it creates. The directive configuration is passed as an additional argument alongside the existing portal options, using inputBinding() to feed values into the directive:
import { ComponentPortal } from '@angular/cdk/portal';
import { inputBinding } from '@angular/core';
import { NotificationPanel } from './notification-panel';
import { LiveRegionDirective } from './live-region.directive';
protected readonly notificationPortal = computed(() =>
this.showNotification()
? new ComponentPortal(
NotificationPanel,
null, // viewContainerRef
null, // injector
null, // projectableNodes
undefined, // bindings
[
{
type: LiveRegionDirective,
bindings: [inputBinding('label', () => 'Order status update')],
},
],
)
: null,
);
The inline version renders through a template outlet, just like any other ComponentPortal usage:
<ng-template [cdkPortalOutlet]="notificationPortal()"></ng-template>
For the floating version, you attach the same kind of portal — with the same directive, but a different label — to a DomPortalOutlet targeting an element appended straight to the document body:
import { DomPortalOutlet } from '@angular/cdk/portal';
private openFloatingNotification(): void {
const hostElement = this.document.createElement('div');
hostElement.classList.add('floating-notification');
this.document.body.append(hostElement);
const outlet = new DomPortalOutlet(hostElement, this.appRef, this.injector);
outlet.attach(
new ComponentPortal(
NotificationPanel,
null,
null,
null,
undefined,
[
{
type: LiveRegionDirective,
bindings: [inputBinding('label', () => 'Floating order update')],
},
],
),
);
}
Same component, same directive, two different rendering contexts, two different accessible labels — and NotificationPanel itself never has to know any of that. The portal, not the component, decides what host-level behavior fits the place it's being rendered.
Unit testing both patterns
Testing host directive composition is straightforward: mount a host component that applies both directives, trigger the interaction, and check the shared directive's effect on the DOM.
import { Component, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { FavoriteToggleDirective } from './favorite-toggle.directive';
import { ShareLinkDirective } from './share-link.directive';
@Component({
selector: 'app-test-host',
imports: [FavoriteToggleDirective, ShareLinkDirective],
template: `
<div
appFavoriteToggle
itemId="item-1"
appShareLink
[url]="shareUrl()"
status="Card actions"
></div>
`,
})
class TestHost {
protected readonly shareUrl = signal('https://example.com/item-1');
}
describe('FavoriteToggleDirective and ShareLinkDirective composition', () => {
it('applies without a duplicate directive error and shares one status region', () => {
const fixture = TestBed.createComponent(TestHost);
fixture.detectChanges();
const host = fixture.nativeElement.querySelector('[appFavoriteToggle]');
expect(host.getAttribute('data-status')).toBe('Card actions');
host.dispatchEvent(new Event('click'));
fixture.detectChanges();
expect(host.getAttribute('data-status')).toBe('Added to favorites');
});
});
For the Portal directive, the useful thing to verify is that the directive's host attributes actually land on the dynamically rendered component, not just that the portal attaches without throwing.
import { Component, ViewChild } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { CdkPortalOutlet, ComponentPortal, PortalModule } from '@angular/cdk/portal';
import { inputBinding } from '@angular/core';
import { NotificationPanel } from './notification-panel';
import { LiveRegionDirective } from './live-region.directive';
@Component({
selector: 'app-portal-host',
imports: [PortalModule],
template: `<ng-template [cdkPortalOutlet]="portal"></ng-template>`,
})
class PortalHost {
@ViewChild(CdkPortalOutlet) outlet!: CdkPortalOutlet;
protected readonly portal = new ComponentPortal(
NotificationPanel,
null,
null,
null,
undefined,
[
{
type: LiveRegionDirective,
bindings: [inputBinding('label', () => 'Order status update')],
},
],
);
}
describe('NotificationPanel rendered through a portal', () => {
it('receives the live region directive on its host element', () => {
const fixture = TestBed.createComponent(PortalHost);
fixture.detectChanges();
const panel = fixture.nativeElement.querySelector('app-notification-panel');
expect(panel.getAttribute('role')).toBe('status');
expect(panel.getAttribute('aria-label')).toBe('Order status update');
});
});
Both tests check the same kind of thing, really: that behavior composed from the outside actually lands on the element it's supposed to, without requiring the component underneath to know anything about it.
Bonus tips
- Keep shared host directives narrow. The de-duplication fix makes sharing a common directive across multiple composed directives safe, but it's still easiest to reason about if that shared directive does one well-defined thing — a status message, a focus behavior — rather than several.
-
Watch for
NG8024after upgrading, not before. If you already had multiple directives quietly composing the same shared directive under different aliases, that setup was probably throwing the old duplicate-directive error already — this is a good moment to align those aliases rather than something that suddenly needs fixing. -
ComponentPortal's directive argument is verbose on purpose — wrap it if you use it often. Passingnullthrough several unused constructor arguments just to reach the new directives array gets old fast; a small helper function that fills in the boilerplate arguments is worth writing if you're doing this more than once or twice. -
Pair Portal directives with
ApplicationRef.bootstrap()'s new config object for micro frontends. Angular 22 also letsbootstrap()accept a configuration object, including ahostElement, which is a natural complement to Portal-based dynamic rendering when you're mounting Angular components into DOM the framework didn't create. -
Don't reach for Portal directives just to avoid passing an
@Input. This pattern earns its complexity when the same component genuinely needs different host behavior in different rendering contexts — for a component that always needs the same behavior everywhere, a plain input is still simpler.
Recap
Angular 22's CDK improvements are both about the same idea from different angles: composing behavior without duplicating it. Host directive de-duplication means small, focused directives that share a common dependency no longer conflict just because two higher-level directives happen to need the same lower-level one — as long as their aliases agree. And ComponentPortal accepting directives means a dynamically rendered component can pick up context-specific host behavior from wherever it's being rendered, without that behavior getting baked into the component itself.
Neither change is something you'll hit on day one with a simple app, but the moment you're building a shared directive library, or rendering the same component through more than one Portal outlet, both of these are exactly the kind of fix that turns "this shouldn't be this hard" into "oh, that's clean."
What did you think?
Did this approach match how you are solving it, or do you have a different take? Drop a comment — I genuinely read every single one.
Found this helpful?
If this saved you even a few minutes of debugging or confusion, hit that clap button so others can find it too. It really does make a difference.
Want more tips like this?
I share one practical dev insight every week. Follow me here on Medium or subscribe to my newsletter so you never miss one.
Let us connect — find me on LinkedIn or GitHub and let us keep the conversation going.
Follow Me for More Angular & Frontend Goodness:
I regularly share hands-on tutorials, clean code tips, scalable frontend architecture, and real-world problem-solving guides.
- 💼 LinkedIn — Let’s connect professionally
- 🎥 Threads — Short-form frontend insights
- 🐦 X (Twitter) — Developer banter + code snippets
- 👥 BlueSky — Stay up to date on frontend trends
- 🌟 GitHub Projects — Explore code in action
- 🌐 Website — Everything in one place
- 📚 Medium Blog — Long-form content and deep-dives
- 💬 Dev Blog — Free Long-form content and deep-dives
- ✉️ Substack — Weekly frontend stories & curated resources
- 🧩 Portfolio — Projects, talks, and recognitions
- ✍️ Hashnode — Developer blog posts & tech discussions
- ✍️ Reddit — Developer blog posts & tech discussions
Top comments (0)