Preserving Router Component State in Angular with Route Reuse Strategy + CDK Portals

Views:
The article discusses preserving the state of components, such as iframes, in Angular using Route Reuse Strategy and CDK Portals. CDK Portals enable dynamic rendering of UI elements outside their usual DOM hierarchy, preventing issues like iframe reloads during route changes. A service, ThirdPartyIframePortalService, manages iframe rendering, positioning, and cleanup without modifying third-party code. The iframe is mounted at the app root but visually remains in its original location, maintaining its state across routes. This approach ensures smooth transitions, avoids invasive hacks, and prevents memory leaks, making it ideal for stateful components like video players or maps.

In my earlier post, A Practical Implementation of Angular's Route Reuse Strategy, I taught a nifty trick: keep components alive between route hops and stop iframes from reloading by hoisting them into a global wrapper.

That solution worked, but it was invasive—whenever the <iframe> lived inside a third-party component we had to “hack” into it.

React ships with Portals, a magic trapdoor that teleports DOM nodes wherever you fancy.
Good news: Angular has an equivalent in the CDK!

A Portal is a piece of UI that can be dynamically rendered to an open slot on the page.
*(Angular Material CDK docs)

A Portal can be:

  • a Component
  • a TemplateRef

The slot that receives it is a PortalOutlet.

CDK Portals in a Nutshell

Template-driven usage:

html
Copy code
<!-- Captured as a portal --> <ng-template cdkPortal> <p>Template content captured by the portal.</p> </ng-template> <!-- Shorthand syntax --> <p *cdkPortal>Template content captured by the portal.</p>

Programmatic usage:

ts
Copy code
// 1. Create a portal instance const userSettingsPortal = new ComponentPortal(UserSettingsComponent); // 2. Attach it to an outlet <ng-template [cdkPortalOutlet]="userSettingsPortal"></ng-template>

The Iframe Problem (Again)

In the new demo repo (now a pnpm monorepo) the details page contains a third-party iframe component:

html
Copy code
<div class="task-detail-wrapper"> <div class="left"></div> <div class="divider"></div> <div class="right"> <third-party-iframe [src]="iframeUrl"></third-party-iframe> </div> </div>

But as soon as we click away, poof—the iframe evaporates and its internal state resets (insert sad trombone 🎺):

iframe reload gif Time to let CDK Portals keep the iframe alive—without touching the third-party code.

Designing The Service

Our ThirdPartyIframePortalService has it own responsibilities:

  1. Register a single global host container + CdkPortalOutlet.
  2. Attach/detach the ThirdPartyIframe component via a ComponentPortal.
  3. Mirror the size/position of the “placeholder” element so the user still sees the iframe exactly where it belongs.
  4. Clean up ResizeObserver, component refs, and BehaviorSubjects to avoid memory leaks.

Key implementation points (abridged for clarity):

ts
Copy code
@Injectable({ providedIn: 'root' }) export class ThirdPartyIframePortalService { // --- Private state ------------------------------------------------------- private host: ElementRef | null = null; private outlet: CdkPortalOutlet | null = null; private portalRef: ComponentRef<ThirdPartyIframe> | null = null; private resizeObs: ResizeObserver | null = null; // Observable state for layout + visibility private rect$ = new BehaviorSubject<DOMRect | null>(null); private show$ = new BehaviorSubject<boolean>(false); // ------------------------------------------------------------------------- constructor() { combineLatest([this.rect$, this.show$]).subscribe(([r, show]) => show && r ? this.showHost(r) : this.hideHost() ); } /* Public API -------------------------------------------------------------*/ registerHost(outlet: CdkPortalOutlet, hostEl: ElementRef) {} attach(url: SafeResourceUrl, placeholder: ElementRef) {} show() { this.show$.next(true); } hide() { this.show$.next(false); } destroy() {} /* Internals --------------------------------------------------------------*/ private observeRect(el: ElementRef) {} private showHost(rect: DOMRect) {} private hideHost() {} }
Good to know:
ComponentPortal constructor signature is
new ComponentPortal(Cmp, viewContainerRef?, injector?)
viewContainerRefaffects the logical component tree, while the PortalOutlet controls where the component renders in the DOM.

Where to Mount the Portal

app.html

html
Copy code
<div #portalHost> <ng-template cdkPortalOutlet #portalOutlet></ng-template> </div> <router-outlet></router-outlet>

app.component.ts

ts
Copy code
ngAfterViewInit() { this.iframeSvc.registerHost(this.portalOutlet, this.portalHost); // Toggle iframe visibility based on the active route this.router.events.pipe(filter(e => e instanceof NavigationEnd)) .subscribe(e => { const onDetails = (e as NavigationEnd).url.startsWith('/tasks/'); onDetails ? this.iframeSvc.show() : this.iframeSvc.hide(); }); }

Attaching from the Details Page

task-detail.page.html

html
Copy code
<div class="task-detail-wrapper"> <div class="right"> <!-- Placeholder that marks the iframe’s physical position --> <div #iframeContainer class="iframe-placeholder"></div> </div> </div>

task-detail.page.ts

ts
Copy code
@ViewChild('iframeContainer', { static: false }) iframeContainer!: ElementRef; ngAfterViewInit() { this.iframeSvc.attach(this.iframeUrl, this.iframeContainer); }

Finally, the iframe component now is like a magician—it’s rendered just once at the application root but performs a clever trick! It visually pops up exactly where you need it and keeps its internal state intact, no matter how many routes you explore. Ta-da!

iframe retained gif

Cleaning Up When Switching Tasks

Because the iframe now lives at the app root, we must explicitly destroy it when navigating to a different task:

ts
Copy code
async goTo(id: number) { const lastId = await firstValueFrom(this.lastTaskId$); if (id !== lastId) { this.lastTaskService.setLast(id); this.customReuseStrategy.clearDetailsRoute(); this.iframeSvc.destroy(); } await this.router.navigate(['/tasks', id]); }

Conclusion

So what do we get by fusing Route Reuse Strategy with CDK Portals?

  • Retain heavy, stateful components (iframes, video players, maps, etc.) across route changes.
  • Avoid invasive “global component” hacks—no need to touch the third-party code.
  • Keep full control over cleanup to prevent memory leaks.

Give it a try in your own project and enjoy flicker-free iframes!