Modern Angular, Then vs Now (Part 1): Structure and Templates
I learned Angular back in the Angular 6 days, and like many developers, I still spend some of my day maintaining apps built on versions 2 to 14. But the framework has changed significantly since then. Standalone components, new control flow, signals, zoneless change detection and Signal Forms have reshaped how Angular apps are written. If you are starting a new project today or doing an upgrade, it is worth leaving the old patterns behind and building with what Angular has become.
This part series compares the old and new side by side, so a working Angular developer can see what changed, why, and when to reach for each feature in a production app. In Addition, this is for developers who learned Angular with NgModules, *ngIf and RxJS everywhere, and now maintain or start apps on v19 to v22.
If you still type $scope.$apply() i.e., Angular.js(Retired 2021), please shift 😂, this is not for you.
I. Say Goodbye to NgModules (Standalone, inject() and @Service)
Standalone components removed the extra layer of NgModules: each component now declares its own dependencies, so the unit of reuse is the component, not the module. Since v19, standalone: true is implied, so every new component is standalone unless you say otherwise.
The old way: Previously, a component could not be used until it was declared in exactly one module, and that module had to be imported wherever it was needed, causing shared modules to grow into dumping grounds, and lazy loading meant writing a module just to hold a route and just one component at times.
Then (NgModule era):
@NgModule({
declarations: [AppComponent, UserCardComponent],
/* Previously, when declaring a component, you had to use the approach shown one line above.
Now, components are standalone by default, so no additional configuration is needed. */
imports: [BrowserModule, HttpClientModule, AppRoutingModule],
bootstrap: [AppComponent],
})
export class AppModule {}
@Injectable({ providedIn: 'root' })
export class UserService {
constructor(private http: HttpClient) {}
}Now (v19 to v22):
// main.ts
bootstrapApplication(App, {
providers: [provideRouter(routes)],
}); //previously: to bootstrap an app you use platformBrowserDynamic().bootstrapModule(AppModule)
// user.service.ts
@Service() // previously: @Injectable({ providedIn: 'root' })
export class UserService {
private readonly authService = inject(AuthenticationService);
}
// user-card.ts
@Component({
selector: 'app-user-card',
imports: [DatePipe, AvatarComponent],
template: `...`,
})
export class UserCard {
private readonly users = inject(UserService);
// Previously: injection happens via constructor parameters
}
// routes.ts
export const routes: Routes = [
{ path: 'admin', loadChildren: () => import('./admin/admin.routes') },
//Previously: to achieve lazy loading you have to loadChildren to a feature module but now you loadComponent or loadChildren to a routes file.
{ path: 'users/:id', loadComponent: () => import('./user-detail') },
];When to use it in a real app:
- New code: Your new components should always be standalone. There is no remaining reason to write a new NgModule; you don’t need it.
- Existing apps: Update one feature at a time, starting with simple components like cards, buttons, directives, and pipes. Standalone and NgModule components can work together, so you don’t need to rewrite the entire app at once.
- Keep an NgModule only where a third-party library still ships one; import that module into a standalone component’s using imports:[].
- Prefer inject() over constructors: it works in functional guards, interceptors and resolvers, and it plays well with inheritance.
- Use injectAsync() (v22) for heavy services needed only after a user action, such as a PDF export service.
II. Cleaner Templates with (@if, @for, @switch, @let, @defer)
In v17+, built-in control flow replaces structural directives with block syntax that is compiled, not imported. You get better type narrowing, a mandatory track for lists, and no CommonModule import just to write an *ngIf directive.
While the old pain *ngIf with else needed a separate <ng-template> reference, *ngFor silently re-rendered lists without trackBy, and [ngSwitch] spread one decision over three directives.
Get Jeremiah Udoh’s stories in your inbox
Join Medium for free to get updates from this writer.
Then:
<div *ngIf="user$ | async as user; else loading">
<ul>
<li *ngFor="let order of user.orders; trackBy: trackById">{{ order.ref }}</li>
</ul>
<p *ngIf="!user.orders.length">No orders yet</p>
<ng-container [ngSwitch]="user.tier">
<gold-badge *ngSwitchCase="'gold'"></gold-badge>
<basic-badge *ngSwitchDefault></basic-badge>
</ng-container>
</div>
<ng-template #loading><spinner></spinner></ng-template>Now:
@let current = user();
/* Previously, to achieve a local template variable you have to used the async
hack *ngIf="obs$ | async as data" */
@if (current) {
<ul>
@for (order of current.orders; track order.id) {
<li>{{ order.ref }}</li>
} @empty {
/* Previously, to specify you'd have needed an extra
block of *ngIf="!users.length" */
<p>No orders yet</p>
}
</ul>
@switch (current.tier) {// Previously, [ngSwitch], *ngSwitchCase, *ngSwitchDefault
@case ('gold') { <gold-badge /> }
@case ('silver') { <silver-badge /> }
@default never; /* Previously: specifying NEVER wasn't possible in an *ngswitch directive.
from v21.2+ this now available */
}
} @else {
/* Previously, to achieve this type of conditional if else with loading spinner you have to use
*ngIf="user; else loading" + <ng-template #loading>*/
<spinner />
}@defer (on viewport; prefetch on idle) {
/* Previously, to lazy load a chuck of UI, you could only use a
Router-only, or manual dynamic components */
<order-analytics-charhtt [orders]="current?.orders" />
} @placeholder {
<div class="chart-skeleton"></div>
} @loading (minimum 300ms) {
<spinner />
}When to use it in a real app:
- where you’ve been using *ngif, *ngfor, *ngSwitch directive you now need to start using @if / @for / @switch everywhere. They are the default now; you don’t want to use deprecated directives as part of your code going forward.
- track: use a stable ID (order.id). Use $index only for static lists that never reorder.
- @default never: This is used with a switch case, never alone. It is used on union-typed status fields, i.e. enums, in simple terms, e.g(
'PENDING' | 'APPROVED' | 'REJECTED') so the compiler flags a new status you forgot to render if the union type has changed. - @let: use it to read a signal once and reuse it, or to name a long expression. Do not use it to hold business logic.
- @defer: use it for heavy, below-the-fold or rarely opened UI: charts, rich text editors, maps, admin panels. Do not defer content needed for first paint or SEO, and avoid deferring tiny components (the extra chunk costs more than it saves).
In Part 2, we drop Zone.js, meet signals, and find out why your setTimeout suddenly stopped updating the UI.








