DEV Community

Cover image for Teleport a Component in Angular (and Keep Its State)
Brian Treese
Brian Treese

Posted on • Originally published at briantree.se

Teleport a Component in Angular (and Keep Its State)

Have you ever moved a component across your layout and watched your state just vanish? In this tutorial, we'll try to avoid this with three different approaches: ng-template with ngTemplateOutlet, the CDK Template Portal, and the CDK DomPortal. You’ll see when Angular recreates views and how to move a live component without losing state. Stick around until the end and you’ll leave with a one-line rule you’ll never forget...

"Templates recreate things while the DomPortal moves them"

Demo Setup: Moving a Component Across Layouts

Here’s the app that we’re working with in this tutorial:

An Angular demo application with a promo banner displayed in the sidebar

We’ve got a small admin dashboard with a "promo banner" displayed in the sidebar:

Toggling the promo banner between the sidebar and the main content region

When we click a button, the banner jumps to the main content region:

The promo banner displayed in the sidebar

This banner includes a heart button and a timer:

Pointing out the heart button and the timer in the promo banner

But when we switch locations, the state resets, the timer restarts, and the heart unlikes because the component is being reinitialized every time it moves.

Let’s take a look at some code to get an understanding of the current logic.

Let’s start with the template for the root component where this banner is displayed.

First, we have the button that is used to toggle the region that the promo banner displays in:

<button class="btn" (click)="togglePlacement()">
    Move Promo {{ dockRight() ? 'to Content' : 'to Sidebar' }}
</button>
Enter fullscreen mode Exit fullscreen mode

Then, we have two conditional regions based on this dockRight() signalthat will show the banner either in the sidebar or the main content region:

In the main content region:

@if (!dockRight()) {
    <promo-banner></promo-banner>
}
Enter fullscreen mode Exit fullscreen mode

In the sidebar:

@if (dockRight()) {
    <promo-banner></promo-banner>
}
Enter fullscreen mode Exit fullscreen mode

Now, let’s look at the TypeScript for this component.

Right now this thing is pretty simple.

First, we have the dockRight() signal defined:

const dockRight = signal(false);
Enter fullscreen mode Exit fullscreen mode

Then, we have the togglePlacement() method that is used to toggle the dockRight() signal:

togglePlacement() {
    dockRight.set(!dockRight());
}
Enter fullscreen mode Exit fullscreen mode

In this tutorial we’ll try to render this banner in three different ways:

  • First using ng-template with ngTemplateOutlet
  • Then using the CDK TemplatePortal
  • Finally using the CDK DomPortal

And we’ll observe when state resets and when it persists.

Part 1: Using ng-template and ngTemplateOutlet

First, we’re going to try to use an ng-template and the ngTemplateOutlet directive.

In order to do this, we first need to add it to the component imports array:

import { NgTemplateOutlet } from '@angular/common';

@Component({
  selector: 'app-root',
  ...,
  imports: [ NgTemplateOutlet ],
}
Enter fullscreen mode Exit fullscreen mode

Now, in the template, we need to define an ng-template with our promo-banner component that we can reuse:

<ng-template #promo>
    <promo-banner></promo-banner>
</ng-template>
Enter fullscreen mode Exit fullscreen mode

On this template, we have also added a reference variable #promo so that we can access it later.

So, what this will do is allow us to essentially stamp out this component in multiple different locations as needed.

And how do we do that?

Well, we’ll use the ngTemplateOutlet directive.

To do this, we'll replace the existing conditional promo-banner components with an ngTemplateOutlet directive that references our #promo template:

In the main content region:

@if (!dockRight()) {
    <ng-template [ngTemplateOutlet]="promo"></ng-template>
}
Enter fullscreen mode Exit fullscreen mode

In the sidebar:

@if (dockRight()) {
    <ng-template [ngTemplateOutlet]="promo"></ng-template>
}
Enter fullscreen mode Exit fullscreen mode

Now, after we save, how does it work?

The promo banner moving but no persisting state using ng-template and ngTemplateOutlet

Well, this banner still successfully appears in both locations, but the state is reset every time.

This is because when we switch the banner between the two locations, Angular creates a brand new embedded view each time.

New view, new component instance, new state.

So let’s try something different, a CDK TemplatePortal.

Part 2: Trying the CDK TemplatePortal

Next, we’ll use the Angular CDK Portal Module.

The CDK gives us three kinds of portals:

Since TemplatePortal is closest to what we already had, we’ll try it first.

With a TemplatePortal, we can pass around our template and attach it wherever we need it.

First, a quick note, you'll need the Angular CDKinstalled.

You'll just need to run this command in your project root to install it:

npm install @angular/cdk
Enter fullscreen mode Exit fullscreen mode

Okay, once we have the CDK installed, we can import the PortalModule in our component so that we can use it:

import { PortalModule } from '@angular/cdk/portal';

@Component({
  selector: 'app-root',
  ...,
  imports: [ PortalModule ],
}
Enter fullscreen mode Exit fullscreen mode

Now we need to add a few new properties to this component.

First, let’s add a “promoContent” property to hold the value for the portal:

import { ..., TemplatePortal } from '@angular/cdk/portal';

protected promoContent!: TemplatePortal<unknown>;
Enter fullscreen mode Exit fullscreen mode

Next, we need a property to access the template.

Let’s call it “promo” and set it to the template we made using the viewChild()signal query:

import { ..., viewChild } from '@angular/core';

private readonly promo = viewChild.required<TemplateRef<unknown>>('promo');
Enter fullscreen mode Exit fullscreen mode

We’ll also need to inject the ViewContainerReffor the origin of the view:

import { ..., inject, ViewContainerRef } from '@angular/core';

private viewContainerRef = inject(ViewContainerRef);
Enter fullscreen mode Exit fullscreen mode

Now, we need to set the portal whenever the viewChild is resolved, so let’s add a constructor with an effect():

import { ..., effect } from '@angular/core';

constructor() {
    effect(() => {
    })
}
Enter fullscreen mode Exit fullscreen mode

Then we’ll check for the existence of our promo() template, and if it exists, we’ll set the portal using the TemplatePortal:

constructor() {
    effect(() => {
        if (this.promo()) {
            this.promoContent = new TemplatePortal(this.promo(), this.viewContainerRef);
        }
    })
}
Enter fullscreen mode Exit fullscreen mode

As you can see we need to pass this function both the template and the ViewContainerRef.

Alright, that's all we need here, so now we go back to the component template and switch to the TemplatePortal.

All we need to do is replace ngTemplateOutlet with cdkPortalOutlet and pass it our new promoContent property:

In the main content region:

@if (!dockRight()) {
    <ng-template [cdkPortalOutlet]="promoContent"></ng-template>
}
Enter fullscreen mode Exit fullscreen mode

In the sidebar:

@if (dockRight()) {
    <ng-template [cdkPortalOutlet]="promoContent"></ng-template>
}
Enter fullscreen mode Exit fullscreen mode

Okay, after we save, how does it work now?

The promo banner moving but not persisting state using the CDK TemplatePortal

Well, when we switch the banner location, the attach process kicks in and… just like before, we get a new embedded view.

That means the state still resets.

Now we could try a ComponentPortal here but I’ll go ahead and spoil it for you… we’d end up with the same result.

So TemplatePortals don’t solve our problem either.

Let’s move on to the real winner.

Part 3: DomPortal (The Win)

With a DomPortal, we can actually move the same live instance of the component.

It literally re-parents existing DOM into another outlet.

Same instance, same signals, same timers.

To use it, back in the TypeScript, we need to change a few things.

First, we need to switch the promoContent property from a TemplatePortal to a DomPortal typed as an HTMLElement this time:

import { ..., DomPortal } from '@angular/cdk/portal';

protected promoContent!: DomPortal<HTMLElement>;
Enter fullscreen mode Exit fullscreen mode

We also need to switch the promo viewChild from a TemplateRef to an ElementRef:

import { ..., ElementRef } from '@angular/core';

private readonly promo = viewChild.required<ElementRef>('promo');
Enter fullscreen mode Exit fullscreen mode

For the DomPortal we no longer need the ViewContainerRef so it can be removed.

Finally, in the effect() we can switch from the TemplatePortal to the DomPortal:

constructor() {
    effect(() => {
        if (this.promo()) {
            this.promoContent = new DomPortal(this.promo());
        }
    })
}
Enter fullscreen mode Exit fullscreen mode

Okay, that’s everything we need to change in the TypeScript, now we just need to make a few changes in the component template.

Basically, we just need to switch to use real elements instead of templates:

The shared instance:

<div #promo>
    <promo-banner></promo-banner>
</div>
Enter fullscreen mode Exit fullscreen mode

In the main content region:

@if (!dockRight()) {
    <div [cdkPortalOutlet]="promoContent"></div>
}
Enter fullscreen mode Exit fullscreen mode

In the sidebar:

@if (dockRight()) {
    <div [cdkPortalOutlet]="promoContent"></div>
}
Enter fullscreen mode Exit fullscreen mode

Alright, that’s it. Let’s save and check it out now!

The promo banner moving and persisting state using the CDK DomPortal

Nice! Now when we move the banner:

  • The timer keeps ticking
  • The like stays liked
  • No reset happens!

A DomPortal even preserves the origin injector, so feature-scoped providers and DI context remain intact.

This is the approach that lets us teleport a component without losing state.

Key Takeaways: Templates Recreate vs. DomPortal Moves

Here’s the simple rule to remember:

  • Rendering the banner directly, with ng-template and ngTemplateOutlet, or with a TemplatePortal all recreate the view, so state resets.
  • A DomPortal actually moves the same instance, so state persists.

That’s the whole trick.

The Portal Module is just one of many hidden gems in the Angular CDK.

If you want to see more lesser-known Angular features that can level up your applications, don’t forget to subscribe!

Additional Resources

Want to See It in Action?

Want to experiment with the final version? Explore the full StackBlitz demo below. If you have any questions or thoughts, don’t hesitate to leave a comment.

Top comments (0)