Topics covered in this article:
- How it works today
- How it can be done in Angular v16
- How to migrate to the new API
- How to test it
- Caveats
How it works today
When working with Angular, we often need to render dynamic components. For example, we might want to render a component based on the user's input. In order to do that, we can use NgComponentOutlet
directive.
So, we will take a look at how we can pass data to dynamically created components using NgComponentOutlet
directive today.
Example:
Let's say we need to show a component based on the type we choose in a dropdown.
In order to pass data to dynamic components rendered by NgComponentOutlet directive, we have to:
- Create an injection token
- Create a new injector
- Pass the data in the injector using the injection token.
- Pass the injector to the
NgComponentOutlet
directive. - Use the injection token to get the data in the dynamic component.
So, first we need to create an injection token.
export interface DynamicData {
url: string;
updated: (changes: any) => void; // callback to update the data
}
export const DATA_TOKEN = new InjectionToken<DynamicData>("data");
Now, let's take a look at our dynamic components, ImageComponent
and VideoComponent
, and how we can use the injection token to get the data in the dynamic component.
@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
}
Pretty "complex" usecase I know, but this is just for demonstration purposes.
Now, let's take a look at how we can use the NgComponentOutlet
directive to render the dynamic components.
@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];
}
}
As you can see, we have to create a new injector for each dynamic component, register the data we want to pass to the injection token and then and pass it to the NgComponentOutlet
directive using the selectedItem
. We also pass an updated
callback to the value. This callback is used to update the data in the parent component (in our case it just logs). So, it will work like an event emitter (output).
Yeah! This is a lot of boilerplate code 😬.
But, it's getting better in Angular v16 🥳.
How it can be done in Angular v16
In Angular v16, we can pass data to dynamically created components using the NgComponentOutlet
directive using the inputs
property 🤩.
First thing we will do is to covert our ImageComponent
and VideoComponent
to use the @Input() decorator.
@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;
}
It's pretty simple, right?
Now, let's take a look at how we can use the inputs
property to pass data to dynamically created components.
@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];
}
}
As you can see, we can pass the data directly to the inputs
property. No need to create a new injector and register the data in the injector. We can also do the same trick with the callback to update the data in the parent component.
How to migrate to the new approach
If you are using the old approach, you can migrate to the new approach by doing the following:
- Convert your dynamic components to use the
@Input()
decorator instead of theinject()
function.
Before:
@Component({})
export class ImageComponent {
data = inject(DATA_TOKEN);
}
After:
@Component({})
export class ImageComponent {
@Input() data: DynamicData;
}
- Pass the data directly to the
inputs
property of theNgComponentOutlet
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 injected
Injector
from the parent component but also the InjectionToken created to pass the data to the dynamic component.
How to test it
To test the new approach, we will use the TestBed
to create a test module and then create a test component that uses the NgComponentOutlet
directive.
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 our testing approach is just testing only what we see on the screen, then the tests should not change at all. We are still testing the same thing. We are just using a different approach to create the dynamic components.
Caveats
We are used to use the @Output() decorator to tell Angular that we want to listen to an event from the child component. But the NgComponentOutlet
directive won't support the @Output()
decorator or add an outputs field. So, we are left with the callback approach to notify the parent component about the changes in the child component.
Conclusion
I hope you enjoyed this article, and I hope that you will find this new feature useful.
If you have any questions or suggestions, feel free to leave a comment below.
This is the PR (community contribution) that implements the feature: https://github.com/angular/angular/pull/49735
Thanks to HyperLife1119 😎
Thanks for reading!
I tweet a lot about Angular (latest news, videos, podcasts, updates, RFCs, pull requests and so much more). If you’re interested about it, give me a follow at @Enea_Jahollari. Give me a follow on dev.to if you liked this article and want to see more like this!
Top comments (1)
This feature is still not merged . github.com/angular/angular/pull/49735