My dashboard renders components it has never met
ComponentFactoryResolver is dead and unmourned. Modern dynamic components are ViewContainerRef.createComponent, setInput for change-detection-safe bindings, and NgComponentOutlet when the template should decide.
The requirement sounds exotic until the day it lands on your sprint: the server sends a JSON layout — a list of widget descriptors — and the app has to render whatever comes back. A chart here, a KPI tile there, a table if the user configured one. The template can't @if its way through this, because the template doesn't know the shape. The component type itself is data.
If your memory of dynamic components involves ComponentFactoryResolver, entry components, and a ceremony that felt like summoning something, good news: all of that is gone. The factory resolver was removed back in v13. What's left is almost embarrassingly small.
@Component({ /* ... */ })
export class DashboardComponent {
private host = viewChild.required('slot', { read: ViewContainerRef });
renderWidget(descriptor: WidgetDescriptor) {
const ref = this.host().createComponent(WIDGETS[descriptor.type]);
ref.setInput('config', descriptor.config);
return ref;
}
}createComponent takes the component class. Directly. The registry WIDGETS is a plain object mapping descriptor types to imported classes. That's the entire trick.
setInput is not optional
The mistake that costs an afternoon: setting inputs by assigning to the instance. ref.instance.config = descriptor.config compiles, appears to work in a demo, and then betrays you twice in production. Under OnPush, a bare property assignment doesn't mark the component for check, so the widget renders stale. And if the widget uses signal inputs — config = input.required<WidgetConfig>() — the instance property isn't even writable that way.
ref.setInput('config', value) is the API that goes through the front door: it sets the input the same way a template binding would, marks for check, and works identically for decorator inputs and signal inputs. It's also what makes re-rendering on config change trivial — call it again with the new value instead of destroying and recreating the widget.
Outputs and cleanup
Outputs are just there on the instance. With the modern output() function they expose subscribe directly:
const ref = this.host().createComponent(ChartWidget);
const sub = ref.instance.pointSelected.subscribe((p) => {
this.openDetail(p);
});Cleanup has one rule worth memorizing: a component created into a ViewContainerRef is part of that view tree, so when the container's host dies, your dynamic components die with it. You only call ref.destroy() yourself when a widget is removed while the dashboard lives on — user deletes a tile, layout reloads. Forgetting that case leaks the component and its subscriptions; I register the output subscription with DestroyRef or tie it to the ref's own destruction so the two can't drift apart.
NgComponentOutlet, for when the template should decide
A surprising number of "dynamic component" cases don't need any of the imperative machinery. If you just need render this type with these inputs in a template position:
@for (w of widgets(); track w.id) {
<ng-container
[ngComponentOutlet]="registry[w.type]"
[ngComponentOutletInputs]="w.config"
/>
}ngComponentOutletInputs (since v16.2) does the setInput dance for you, including diffing the object between checks. No ViewContainerRef, no refs to track, no manual destroy — the @for owns the lifecycle. I reach for the imperative API only when I need the ComponentRef itself: subscribing to outputs, calling methods on the instance, moving it between containers.
When not to do any of this
The boundary that keeps a codebase sane: dynamic instantiation is for when the component type arrives as data. If the set of possibilities is known at build time and you're choosing based on state, a @switch over the cases — or @defer if the goal is loading less upfront — is simpler, type-checked against actual inputs, and friendlier to the optimizer. A registry object holding component classes makes every one of them eagerly-bundled and invisible to template type checking; setInput('confg', ...) is a runtime error, not a compile error. I've seen a plugin architecture built where three @if branches would have done. The JSON-driven dashboard genuinely needs this machinery. Most screens don't — and now that the machinery is this small, the temptation to use it everywhere is the real danger.