From *ngIf to @if: Angular's built-in control flow explained
Angular v17 introduced one of the most visible syntax changes the framework has ever made. Structural directives -- the *ngIf, *ngFor, and *ngSwitch that Angular developers have used for over a decade -- now have built-in replacements. The new syntax uses @if, @for, @switch, and a handful of companion blocks that change how you write templates in a fundamental way.
This is not a cosmetic change. The new control flow is compiled differently, performs better in many scenarios, and aligns Angular's template syntax with what developers expect from modern frameworks. If you are starting a new project or migrating an existing one, understanding the new syntax is essential. And if you use an AI code assistant, your cursor rules need to know about it too.
Why the Angular team made this change
Structural directives have always been one of Angular's more confusing features for newcomers. The asterisk syntax (*ngIf, *ngFor) is syntactic sugar that Angular desugars into ng-template elements behind the scenes. Most developers never see the desugared form, but it affects how the framework processes templates, and it introduces subtle gotchas.
For example, you cannot put two structural directives on the same element. If you want to conditionally render an item in a loop, you need an extra ng-container wrapper. The "as" syntax for aliasing values in *ngIf is powerful but unintuitive. And the microsyntax (the little DSL inside the directive string) is something many developers use without fully understanding.
The built-in control flow eliminates all of this. The new syntax is plain, block-based, and reads like pseudocode. There is no desugaring, no microsyntax, and no hidden ng-template elements. What you write is what the compiler sees, which makes debugging simpler and mental models clearer.
Performance was another motivation. The new control flow blocks are compiled into more optimized instructions than their directive counterparts. The @for block in particular includes a mandatory track expression that replaces the old trackBy function pattern, making list rendering faster by default rather than by opt-in.
The new syntax: @if, @for, @switch, and friends
The @if block replaces *ngIf. You write it directly in the template, and it supports an optional @else block. You can also chain conditions with @else if, which was awkward to do with *ngIf and often required nested ng-templates.
One improvement that deserves attention is the "as" aliasing. With @if, you can assign the result of an expression to a local variable using a semicolon and "as" keyword. This is cleaner than the old *ngIf="value as alias" pattern and works consistently across all block types.
The @for block replaces *ngFor and introduces a required track expression. With the old *ngFor, you could optionally provide a trackBy function to help Angular identify which items changed in a list. Most developers skipped it, which meant Angular had to re-render more DOM than necessary. The new @for makes tracking mandatory. You write "track item.id" (or whatever your identifier is) directly in the block declaration, and Angular uses it automatically.
The @for block also comes with @empty, which renders when the collection is empty. This replaces the pattern of writing a separate *ngIf to check for an empty array -- a pattern that was so common it became a source of duplicated logic in almost every Angular codebase.
The @switch block replaces *ngSwitch, [ngSwitchCase], and [ngSwitchDefault] with a cleaner block syntax. Instead of three separate directives coordinating through a parent, you get a single @switch block with @case and @default sub-blocks. The result is more readable and harder to misconfigure.
Finally, there is @defer, which is entirely new and has no structural directive equivalent. Deferrable views let you lazy-load parts of your template based on triggers like viewport visibility, user interaction, or a timer. This was previously possible only through manual lazy loading of components, which required significantly more code.
Practical migration tips
The Angular team provides a schematic that automates much of the migration. Running ng generate @angular/core:control-flow-migration will convert most structural directives to the new syntax automatically. It handles *ngIf, *ngFor, and *ngSwitch, including their else templates and trackBy functions.
That said, the schematic is not perfect. There are edge cases it cannot handle, especially around complex microsyntax or custom structural directives. After running the migration, you should review every changed file. Look specifically for *ngIf patterns that used the "then" and "else" template references, because those sometimes need manual adjustment.
If your project is large, consider migrating module by module rather than all at once. The old and new syntax can coexist in the same application, so there is no pressure to convert everything in a single commit. Start with the components that change most frequently, since those are the ones where the cleaner syntax will have the biggest impact on developer productivity.
One common mistake during migration is forgetting the track expression in @for. The compiler will catch this -- it is a hard error, not a warning -- but it can be surprising if you are converting a *ngFor that had no trackBy. You need to decide what to track by. If your items have a unique ID, use that. For simple lists of primitives, you can track by the item itself using "track $index" or "track item."
Another thing to watch for is third-party libraries that provide custom structural directives. These will not be converted by the migration schematic and will continue to work as before. The new control flow only replaces Angular's own built-in directives.
Why this matters for cursor rules
AI code assistants have been trained on years of Angular code that uses *ngIf and *ngFor. When you ask Cursor to generate a component, it will often default to the old syntax unless you tell it not to. This is not a limitation of the AI. It is a reflection of the training data, which overwhelmingly features the old approach.
Cursor rules solve this by giving the AI explicit instructions about which syntax to use. A rule like "Use built-in control flow (@if, @for, @switch) instead of structural directives (*ngIf, *ngFor, *ngSwitch)" is clear enough that the AI will follow it consistently. You can go further and specify that @for must always include a track expression, that @empty should be used when displaying lists, and that @defer should be used for below-the-fold content.
Without these rules, you end up with a codebase where some components use the new syntax and others use the old, depending on whether the human or the AI wrote that particular template. That inconsistency makes code review harder, confuses new team members, and creates unnecessary churn when someone eventually decides to standardize.
The @defer block deserves its own rules
While @if, @for, and @switch are direct replacements for existing features, @defer is genuinely new functionality. It lets you define parts of your template that should be lazy-loaded, along with triggers that control when the loading happens.
You can defer based on viewport visibility (the block loads when the user scrolls to it), interaction (it loads when the user interacts with a specific element), idle time (it loads when the browser is idle), or a timer. You can also show placeholder content while the deferred block is loading and error content if the load fails.
This is powerful, but it is also easy to overuse. Not every component benefits from deferred loading. The overhead of creating a separate chunk and loading it at runtime can actually hurt performance for small components that are likely to be needed immediately. Your cursor rules should specify when @defer is appropriate -- for example, for components below the fold, for heavy third-party widgets, or for admin-only features that most users will never see.
Good rules prevent the AI from wrapping everything in @defer blocks just because it can. Like any optimization, deferred loading should be applied intentionally, not indiscriminately.
Making the transition smooth
The migration from structural directives to built-in control flow is one of the smoother transitions Angular has introduced. The syntax is more intuitive, the compiler catches mistakes early, and the migration schematic handles the bulk of the mechanical work.
The real challenge is consistency. In a team of five or ten developers, plus an AI assistant generating code throughout the day, it is easy to end up with a mix of old and new syntax unless you set explicit standards. Cursor rules are the most efficient way to enforce those standards because they apply automatically every time the AI generates code.
Update your cursor rules to specify the new control flow syntax. Run the migration schematic on your existing templates. Review the results. And once you have committed to the new approach, add a lint rule or a code review checklist item to catch any old syntax that slips through. The builder below generates rules that cover all of this, tailored to your specific Angular version and project conventions.
Try it yourself
Generate cursor rules that enforce the new built-in control flow syntax for your Angular project.
Open the builder