A component is dynamic when you don’t know at compile time which component must be shown — it comes from a configuration, from the server, from a CMS, from the user’s preferences. Angular gives you three levels of tools; the rule is to choose the simplest one that solves the problem.
| Tool | When | Inputs | Outputs | Lazy |
|---|---|---|---|---|
@if / @switch | a small, known set | ✓ normal | ✓ normal | with @defer |
NgComponentOutlet | the component is a value | ✓ inputs | ✕ | with import() |
ViewContainerRef.createComponent | full control | ✓ inputBinding | ✓ outputBinding | ✓ |
createComponent() (function) | outside the tree (toast, dialog) | ✓ | ✓ | ✓ |
The scale of complexity
<!-- Level 1: the set of components is small and known → simple control flow -->
@switch (block().type) {
@case ('text') { <app-text-block [block]="block()" /> }
@case ('image') { <app-image-block [block]="block()" /> }
@case ('video') { <app-video-block [block]="block()" /> }
}
<!-- Level 2: the component is a VALUE (it comes from config, from a map) → NgComponentOutlet -->
<ng-container *ngComponentOutlet="component(); inputs: { block: block() }" />
<!-- Level 3: you need outputs, directives, full control → ViewContainerRef.createComponent -->Level 2: NgComponentOutlet
import { NgComponentOutlet } from '@angular/common';
@Component({
imports: [NgComponentOutlet],
template: `
<ng-container *ngComponentOutlet="component(); inputs: inputs()" />
`,
})
export class BlockRenderer {
readonly block = input.required<Block>();
protected readonly component = computed(() => BLOCKS[this.block().type]); // Type<unknown>
protected readonly inputs = computed(() => ({ block: this.block() }));
}Level 3: a dashboard built from configuration
The complete pattern: a registry (type → lazy import()), a common contract (the Widget interface) and a host that creates the component with bindings (v20+). Each widget is a separate chunk, downloaded only when it appears in the configuration.
// Registry: type → lazy loading. Each widget is a separate chunk.
export const WIDGET_REGISTRY: Record<WidgetType, () => Promise<Type<Widget>>> = {
stats: () => import('./stats-widget').then((m) => m.StatsWidget),
chart: () => import('./chart-widget').then((m) => m.ChartWidget),
quote: () => import('./quote-widget').then((m) => m.QuoteWidget),
todo: () => import('./todo-widget').then((m) => m.TodoWidget),
};
// The common contract — every widget has title and remove
export interface Widget {
readonly title: InputSignal<string>;
readonly remove: OutputEmitterRef<void>;
}„Small components are easy-to-test components.”
Current configuration (this could come from the server)
[
{
"id": 1,
"type": "stats",
"title": "dynamic-components:today"
},
{
"id": 2,
"type": "chart",
"title": "dynamic-components:traffic"
},
{
"id": 3,
"type": "quote",
"title": "dynamic-components:quote-of-the-day"
}
]bindings link the inputs to signals or functions — they update by themselves, without repeated setInput() — and the outputs to functions, without manual subscribe/unsubscribe. You can also attach directives to the created component (directives: [...]). In v22, ComponentFactoryResolver and ComponentFactory were removed: you pass the class directly.
// ✗ Before v20: setInput + manual subscribe + ComponentFactoryResolver (removed in v22!)
const factory = this.resolver.resolveComponentFactory(StatsWidget); // no longer exists in v22
const ref = this.vcr.createComponent(factory);
ref.setInput('title', this.config.title); // must be called again on every change
const sub = ref.instance.remove.subscribe(() => this.removed.emit());
// + manual unsubscribe on destroyOutside the tree: createComponent()
For toasts, dialogs or overlays, the component must be attached directly to <body>, not where it is called. The createComponent function + ApplicationRef.attachView do this. (In real applications you often use @angular/cdk/overlay or the CDK Dialog, which do exactly this for you.)
// A component created outside the tree (dialog, toast, global tooltip)
@Service()
export class ToastService {
private readonly appRef = inject(ApplicationRef);
private readonly injector = inject(EnvironmentInjector);
show(message: string, kind: 'info' | 'ok' | 'bad' = 'info', ms = 3000) {
const ref = createComponent(Toast, {
environmentInjector: this.injector,
bindings: [
inputBinding('message', () => message),
inputBinding('kind', () => kind),
outputBinding('closed', () => close()),
],
});
const close = () => {
this.appRef.detachView(ref.hostView);
ref.destroy();
};
this.appRef.attachView(ref.hostView); // enters change detection
document.body.appendChild(ref.location.nativeElement);
setTimeout(close, ms);
}
}If you only want to load a component later, @defer — lazy loading in templates is simpler. Errors in dynamically created components are caught with onError: Errors: @boundary & ErrorHandler.
When YES, when NO
- A user-configurable layout (dashboard, widgets).
- Pages built from a CMS / page builder (a “text”, “gallery”, “form” block).
- Forms generated from a JSON schema.
- Plugins: modules added without changing the host component.
- Overlays: toast, dialog, tooltip, context menu.
- You have 2–5 known variants →
@switchis simpler, more readable and checked at compile time. - You only want lazy loading →
@deferorloadComponenton a route. - You only want to hide/show something →
@if.
Common pitfalls
- You lose type checking: the name in
inputBinding('title', ...)is a string. Define a contract (an interface) and keep it close to the registry. - Memory leaks with
createComponentoutside the tree: don’t forgetref.destroy()anddetachView. - Re-creation on every change: re-create the component only when the type changes; the rest goes through reactive bindings.
- The injector: a created component inherits the injector of the
ViewContainerRef. For another context, pass aninjectorin the options.