NgModules were Angular's answer to a real problem: how do you organize a large application into logical units, share dependencies, and lazy-load chunks of functionality? The answer worked. It was also verbose, confusing to newcomers, and responsible for some of the most baffling error messages in frontend development.
Standalone components — stable since Angular 15, the default since Angular 17 — solve the same problems with significantly less ceremony. I've migrated a production application through this transition and built new ones from scratch with the standalone model. Here's what actually matters.
The mental model shift
In the NgModule world, a component is a member of a module. The module declares what the component can use. You want to use RouterLink in a component? Add RouterModule to the module's imports. You want to use another component? Either declare it in the same module or import the module that exports it.
This creates a layer of indirection that makes components non-portable. A component's dependencies are defined somewhere else, by someone else, possibly a long time ago. New team members spend their first weeks asking "why can't I use X here" and the answer is always somewhere in a module file.
In the standalone model, the component owns its dependencies:
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
standalone: true,
imports: [NgIf, NgFor, NgClass, RouterLink],
})
export class DashboardComponent { }
Everything the template needs is declared right here. You can open this file cold and know exactly what it depends on without hunting through module trees. That's not a small thing — it compounds across a codebase.
inject() over constructor injection
Standalone components pair naturally with the inject() function, which lets you inject dependencies without a constructor parameter list:
// Old pattern
export class MyComponent {
constructor(
private readonly router: Router,
private readonly authService: AuthService,
private readonly contentService: ContentService,
) {}
}
// New pattern
export class MyComponent {
private readonly router = inject(Router);
private readonly authService = inject(AuthService);
private readonly contentService = inject(ContentService);
}
This isn't just a style preference. inject() works in any injection context — component constructors, service factories, route guards — which means you can extract reusable injection logic into plain functions without creating a service class for it. It also makes the dependencies easier to read at a glance since each one is on its own line with its type visible.
Signals: the reactive layer that finally makes sense
Angular 16 introduced signals as a new reactive primitive, and they fit the standalone model naturally. Where you'd previously reach for BehaviorSubject or complex async pipe chains, signals give you a simpler model:
export class AdminComponent {
readonly inquiries = signal<Inquiry[]>([]);
readonly activeTab = signal<'inquiries' | 'visitors'>('inquiries');
readonly loading = signal(true);
// computed() derives state — recalculates only when dependencies change
readonly inquiryCount = computed(() => this.inquiries().length);
}
The key insight: a signal is just a value with change notification built in. You read it by calling it like a function — this.loading() — and write it with .set() or .update(). Angular tracks which signals a template reads and re-renders only when those specific signals change.
For auth state in particular, signals are a significant improvement. Rather than maintaining a subscription and manually updating component state, you expose a signal from a service and components derive from it with computed():
// Service
readonly session = signal<SessionInfo>(UNAUTHENTICATED);
// Component — updates automatically whenever session changes
readonly navConfig = computed(() => this.buildConfigForRole(this.authService.session()));
No subscriptions. No ngOnDestroy. No memory leaks from forgotten unsubscribes.
Lazy loading without modules
Lazy loading used to require a routing module and a loadChildren callback that imported it. Now it's a single line:
// Before
{
path: 'dashboard',
loadChildren: () => import('./dashboard/dashboard.module').then(m => m.DashboardModule)
}
// After
{
path: 'dashboard',
loadComponent: () => import('./dashboard/dashboard.component').then(c => c.DashboardComponent)
}
The bundle split is identical. The module is gone. For route groups you still have loadChildren, but it now accepts an array of routes rather than a module.
Migration strategy that actually works
If you're migrating an existing app, go leaf-first. Start with components that have no children of their own — the deepest nodes in the component tree. Add standalone: true and move their dependencies into the component's imports array. Then work inward toward shared components, then feature components, then the root.
The Angular CLI has a migration schematic that handles most of this automatically:
ng generate @angular/core:standalone
Run it in stages — it gives you options for converting components, removing unnecessary NgModule imports, and bootstrapping as standalone. Use git diff aggressively between each stage to review what changed.
CommonModule imports. You'll find CommonModule in a lot of module import lists because it's the easy way to get NgIf, NgFor, and the async pipe. In standalone components, import only what you need — NgIf, NgFor, AsyncPipe — individually. It's more verbose initially but makes tree-shaking more effective.
Shared services don't change. Services with providedIn: 'root' work exactly the same. You don't need to touch them. The standalone migration is a component-level change, not a service-level one.
HTTP interceptors move to bootstrapApplication. If you use HTTP interceptors, they're now registered with withInterceptors() in the bootstrapApplication call rather than in a module provider array. Easy to miss, and it fails silently if you forget.
Third-party libraries may lag. Some Angular libraries still export NgModules and haven't published standalone-compatible exports. You can still import a module into a standalone component's imports array — it works — but check whether newer versions of the library offer standalone APIs before importing the whole module.
Is it worth the migration effort?
For a new project: yes, unambiguously. Build standalone from the start. The mental model is simpler, the tooling assumes it, and new Angular features are being designed around it.
For an existing app: it depends on the size and stability of the codebase. A large, stable NgModule-based app that isn't causing problems doesn't urgently need migration. A growing codebase with new features being added regularly will benefit from the migration — but do it incrementally, not all at once.
At M²S² Engineering Group, we help teams make exactly these kinds of decisions — whether to migrate, how to sequence it, and how to do it without disrupting ongoing delivery. If you're navigating a modernization and want to talk it through, let's talk.
Originally published at m2s2.io
Top comments (0)