In this article
- The current pattern for dynamic components
- What Angular v16 introduces
- Migrating to the new API
- Testing considerations
- Known limitations
The current pattern
Angular developers frequently use NgComponentOutlet to insert components dynamically, for instance, when the component type is determined by user interaction. With this directive, passing data into the dynamically instantiated component involves several steps.
Consider this scenario: you have a dropdown that determines whether an ImageComponent or VideoComponent is shown. To supply data to these components via NgComponentOutlet, the typical process is:
- Define an injection token for the data.
- Construct an injector instance.
- Provide the data as a value for the token within that injector.
- Bind that injector to the
NgComponentOutletdirective. - Inside the dynamic component, retrieve the data by injecting the token.
First, the injection token is created:
export interface DynamicData {
url: string;
updated: (changes: any) => void; // callback to update the data
}
export const DATA_TOKEN = new InjectionToken<DynamicData>("data");
Next, the dynamic components—ImageComponent and VideoComponent—must consume the data through the token:
@Component({
template: `
<img [src]="data.url" />
<button (click)="data.updated({ url: 'https://angular.io' })">Update</button>
`,
})
export class ImageComponent {
data = inject(DATA_TOKEN); // will be of type DynamicData
}
@Component({
template: `
<video [src]="data.url" controls></video>
<button (click)="data.updated({ url: 'https://angular.io' })">Update</button>
`,
})
export class VideoComponent {
data = inject(DATA_TOKEN); // will be of type DynamicData
}
This is admittedly a contrived example, used purely for illustration.
To render these components, you would bind the NgComponentOutlet directive like this:
@Component({
template: `
<label for="type">Type</label>
<select [ngModel]="selectedType" (ngModelChanges)="changeType($event)" name="type">
<option value="image">Image</option>
<option value="video">Video</option>
</select>
<ng-container *ngComponentOutlet="selectedItem.component; injector: selectedItem.injector" />
`,
})
export class ParentComponent {
private readonly injector = inject(Injector);
items = {
image: {
component: ImageComponent,
injector: Injector.create({
parent: this.injector,
providers: [{
provide: DATA_TOKEN,
useValue: {
url: "https://angular.io/assets/images/logos/angular/angular.png",
updated: (changes: any) => console.log("Image changes", changes),
},
}],
}),
},
video: {
component: VideoComponent,
injector: Injector.create({
parent: this.injector,
providers: [{
provide: DATA_TOKEN,
useValue: {
url: "https://www.youtube.com/watch?v=QH2-TGUlwu4",
updated: (changes: any) => console.log("Video changes", changes),
},
}],
}),
},
};
selectedType: "image" | "video" = "image";
selectedItem = this.items[this.selectedType];
changeType(type: "image" | "video") {
this.selectedType = type;
this.selectedItem = this.items[this.selectedType];
}
}
A fresh injector is created for each dynamic component, with the data registered against the token and then passed to the directive via selectedItem. An updated callback is also supplied, which lets the child invoke a function in the parent (in this case it merely logs). This functions as an output mechanism.
That is a significant amount of boilerplate.
The good news is Angular v16 simplifies this considerably.
The Angular v16 approach
Angular v16 introduces an inputs property on NgComponentOutlet, allowing you to pass data directly to the dynamic component's inputs.
The first step is to refactor ImageComponent and VideoComponent to use the @Input() decorator instead of the injection token:
@Component({
template: `
<img [src]="url" />
<button (click)="updated({ url: 'https://angular.io' })">Update</button>
`,
})
export class ImageComponent {
@Input() url: string;
@Input() updated: (changes: any) => void;
}
@Component({
template: `
<video [src]="url" controls></video>
<button (click)="updated({ url: 'https://angular.io' })">Update</button>
`,
})
export class VideoComponent {
@Input() url: string;
@Input() updated: (changes: any) => void;
}
Now, the template updates to pass data through the inputs property:
@Component({
template: `
<label for="type">Type</label>
<select [ngModel]="selectedType" (ngModelChanges)="changeType($event)" name="type">
<option value="image">Image</option>
<option value="video">Video</option>
</select>
<ng-container *ngComponentOutlet="selectedItem.component; inputs: selectedItem.inputs" />
`,
})
export class ParentComponent {
items = {
image: {
component: ImageComponent,
inputs: {
url: "https://angular.io/assets/images/logos/angular/angular.png",
updated: (changes: any) => console.log("Image changes", changes),
},
},
video: {
component: VideoComponent,
inputs: {
url: "https://www.youtube.com/watch?v=QH2-TGUlwu4",
updated: (changes: any) => console.log("Video changes", changes),
},
},
};
selectedType: "image" | "video" = "image";
selectedItem = this.items[this.selectedType];
changeType(type: "image" | "video") {
this.selectedType = type;
this.selectedItem = this.items[this.selectedType];
}
}
The injection token and the manually constructed injector are no longer necessary. The callback mechanism for parent communication remains the same.
Migration path
The transition from the old pattern is straightforward. Here are the steps:
- Replace the
inject()token consumption in your dynamic components with@Input()properties.
Before:
@Component({})
export class ImageComponent {
data = inject(DATA_TOKEN);
}
After:
@Component({})
export class ImageComponent {
@Input() data: DynamicData;
}
- Bind the data directly to the
inputsproperty of the directive.
Before:
@Component({
template: `
<ng-container *ngComponentOutlet="item.component; injector: item.injector" />
`,
})
export class ParentComponent {
private readonly injector = inject(Injector);
items = {
image: {
component: ImageComponent,
injector: Injector.create({
parent: this.injector,
providers: [{
provide: DATA_TOKEN,
useValue: {
url: "https://angular.io/assets/images/logos/angular/angular.png",
updated: (changes: any) => console.log("Image changes", changes),
},
}],
}),
},
};
}
After:
@Component({
template: `
<ng-container *ngComponentOutlet="item.component; inputs: item.inputs" />
`,
})
export class ParentComponent {
items = {
image: {
component: ImageComponent,
inputs: {
data: {
url: "https://angular.io/assets/images/logos/angular/angular.png",
updated: (changes: any) => console.log("Image changes", changes),
},
},
},
};
}
- Remove the
Injectorand the token from the parent component.
Testing
To test the new input-based approach, set up a test module with TestBed and create a host component that uses NgComponentOutlet.
describe('ParentComponent', () => {
let component: ParentComponent;
let fixture: ComponentFixture<ParentComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ParentComponent, ImageComponent, VideoComponent],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ParentComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display image by default', () => {
const imageElement = fixture.debugElement.query(By.css('img'))
.nativeElement as HTMLImageElement;
expect(imageElement.src).toBe(
'https://angular.io/assets/images/logos/angular/angular.png'
);
});
it('should switch to video', () => {
// select video option from the dropdown
const selectElement = fixture.debugElement.query(By.css('select'))
.nativeElement as HTMLSelectElement;
selectElement.value = 'video';
selectElement.dispatchEvent(new Event('change'));
fixture.detectChanges();
const videoElement = fixture.debugElement.query(By.css('video'))
.nativeElement as HTMLVideoElement;
expect(videoElement.src).toBe(
'https://www.youtube.com/watch?v=QH2-TGUlwu4'
);
});
it('should update image data', () => {
spyOn(console, 'log');
const imageUpdateButton = fixture.debugElement.query(By.css('button'));
imageUpdateButton.triggerEventHandler('click', null);
expect(console.log).toHaveBeenCalledWith('Image changes', {
url: 'https://angular.io',
});
});
it('should update video data', () => {
spyOn(console, 'log');
const selectElement = fixture.debugElement.query(By.css('select'))
.nativeElement as HTMLSelectElement;
selectElement.value = 'video';
selectElement.dispatchEvent(new Event('change'));
fixture.detectChanges();
const videoUpdateButton = fixture.debugElement.query(By.css('button'));
videoUpdateButton.triggerEventHandler('click', null);
expect(console.log).toHaveBeenCalledWith('Video changes', {
url: 'https://angular.io',
});
});
});
If your tests focus solely on the rendered output, the test expectations won't change. The testing philosophy remains the same—the only difference is the internal mechanism used to supply the data.
Limitations
The @Output() decorator is not supported by NgComponentOutlet; there is no corresponding outputs property. Therefore, communication from the child back to the parent still relies on callbacks passed through the inputs.
Wrap-up
I hope you find this new capability valuable.
Questions and feedback are welcome in the comments.
The feature was implemented in this community PR: https://github.com/angular/angular/pull/49735
Thanks to HyperLife1119 😎
Thank you for reading!
I regularly share news about Angular—including updates, videos, podcasts, RFCs, and pull requests—on X. If you are interested, you can follow me at @Enea_Jahollari. You can also follow me on dev.to for more articles like this one.
