Angular NotesRohit’s interview study guide
Chapter 04

Directives & Control Flow

Attribute directives, the new built-in control flow, and the ng-container/ng-template plumbing behind the old structural directives.

11 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#What directives are

A directive is a class that Angular attaches to an element to give it extra behaviour. You don't create directives with new, and you don't call them. You write a selector, add the class to a component's imports, and the compiler attaches a new instance to every element in that component's template that matches the selector. The directive can then read inputs, listen to the element's events, and change the element's properties, classes and styles.

There are three kinds:

KindWhat it doesExamples
ComponentA directive with its own template — it owns a piece of the viewevery @Component
Attribute directiveChanges the appearance or behaviour of the element it sits onNgModel, RouterLink, NgClass, your appHighlight
Structural directiveAdds or removes DOM by rendering an <ng-template> zero or more timesNgIf, NgFor, NgSwitch, NgTemplateOutlet

A useful mental model: a component is a directive plus a view. Everything a directive can do — inputs, outputs, host bindings, lifecycle hooks, injection — a component can do too (see Components vs directives).

What changed in modern Angular is the third row. The everyday structural directives *ngIf, *ngFor and *ngSwitch are replaced by built-in control flow blocks — @if, @for, @switch — that aren't directives at all. They're template syntax the compiler understands directly, so there's nothing to import. NgIf, NgForOf and NgSwitch are marked @deprecated 20.0 in Angular 22's .d.ts files ("Use the @if block instead. Intent to remove in a future major release"). Structural directives as a concept still matter — ngTemplateOutlet, library directives, and every Angular 16 codebase like techStatus use them — which is why this chapter covers both.

#Attribute directives

An attribute directive is the smallest useful directive: it sits on an element and changes how it looks or behaves. From Rohit's notes: a directive that changes a paragraph's background colour while it's hovered.

The selector is written in square brackets — [appHighlight] — because it's a CSS attribute selector: "match any element that has an appHighlight attribute". That's why you use it as <p appHighlight>, not <app-highlight>. (Selectors are covered in Selectors.)

highlight.directive.ts
// Modern (Angular 22): host metadata + a signal input.
@Directive({
  selector: '[appHighlight]',
  host: {
    '(mouseenter)': 'hovered.set(true)',
    '(mouseleave)': 'hovered.set(false)',
    '[style.background-color]': 'hovered() ? color() || "yellow" : null',
  },
})
export class Highlight {
  color = input('', { alias: 'appHighlight' });
  protected hovered = signal(false);
}
AdvancedHighlight directive demo, step by step
highlight-demo.ts
@Component({
  selector: 'app-highlight-demo',
  imports: [Highlight],
  template: `
    <p appHighlight>Default yellow</p>
    <p appHighlight="lightblue">Static value</p>
    <p [appHighlight]="favourite()">Bound value</p>
  `,
})
export class HighlightDemo {
  favourite = signal('lightgreen');
}
What’s happening
  1. The compiler sees Highlight in imports and matches [appHighlight] against every element in the template. All three <p>s match — including the third, because [appHighlight]="…" counts as having that attribute name — so three separate instances are created (the test counts 3 with By.directive(Highlight)). Each has its own hovered signal.
  2. The host object binds to the host element — the <p> the directive sits on. '(mouseenter)' adds a listener; '[style.background-color]' is a style binding on the host. It's the same binding syntax as a template, just written as keys and values.
  3. input('', { alias: 'appHighlight' }) makes the attribute's value the input. The first <p> has an empty value, so color() is '' and the || "yellow" fallback applies. The second gets the static string 'lightblue'. The third is property-bound and gets 'lightgreen' — and has no apphighlight attribute in the DOM at all, because property binding sets the input, not an attribute.
  4. Dispatching mouseenter sets hovered to true; the host binding re-evaluates and the test sees style="background-color: yellow;", lightblue and lightgreen. On mouseleave the binding returns null, which removes the style.
  5. favourite.set('orange'), then hover again: the third <p> turns orange. Because color is a signal input, the host binding reads it fresh — nothing to recompute by hand.
AdvancedOlder syntax: ElementRef and Renderer2 directive

The same directive the Angular 16 way — what you'll find in techStatus:

highlight-old.directive.ts
// Old style (Angular 16): ElementRef + Renderer2 + @HostListener + @Input.
@Directive({ selector: '[appHighlightOld]' })
export class HighlightOld {
  @Input() appHighlightOld = '';

  constructor(
    private el: ElementRef<HTMLElement>,
    private renderer: Renderer2,
  ) {}

  @HostListener('mouseenter') onEnter() {
    this.renderer.setStyle(this.el.nativeElement, 'background-color', this.appHighlightOld || 'yellow');
  }

  @HostListener('mouseleave') onLeave() {
    this.renderer.removeStyle(this.el.nativeElement, 'background-color');
  }
}
What’s happening
  1. ElementRef is injected and wraps the host element (el.nativeElement is the <p>). Renderer2 is Angular's abstraction for DOM writes, so the directive doesn't touch the DOM directly (that matters for server rendering).
  2. @HostListener('mouseenter') is the decorator form of the '(mouseenter)' host key. @HostBinding('style.backgroundColor') would be the decorator form of a host binding.
  3. The behaviour is imperative: on enter, call setStyle; on leave, call removeStyle. The modern version is declarative — one binding computed from state — so there's no way to forget the "undo" half.
  4. In the test, <p appHighlightOld="pink"> turns pink on mouseenter and its style is cleared on mouseleave, the same as the modern directive. Both forms still work in Angular 22, but the API docs for @HostListener and @HostBinding say to "Always prefer using the host property" instead.
AdvancedEdge case: bare attribute input value

#@if and @else

@if is the built-in way to render part of a template conditionally. When the condition is truthy, the block's content is created — real DOM nodes, real child component instances. When it becomes falsy, that content is destroyed. It isn't hidden with CSS; it's not in the DOM at all.

welcome.ts
@Component({
  selector: 'app-welcome',
  template: `
    @if (loading()) {
      <p>Loading…</p>
    } @else if (user(); as u) {
      <p>Welcome, {{ u.name }}</p>
      @if (u.admin) {
        <a href="/admin">Admin</a>
      }
    } @else {
      <p>Please sign in</p>
    }
  `,
})
export class Welcome {
  loading = signal(true);
  user = signal<User | null>(null);
}
What’s happening
  1. With loading true, only the first branch exists. The rendered HTML is <p>Loading…</p> plus some <!--container--> comments — those are anchors Angular leaves in the DOM to remember where each branch goes when it's created later.
  2. loading.set(false): the next check evaluates the chain top to bottom. user() is null, so the @else wins. Angular destroys the "Loading…" <p> and creates <p>Please sign in</p>.
  3. user.set({ name: 'Rohit', admin: true }): the @else if branch wins. as u stores the condition's value in a template variable u, typed as User (null has been ruled out), so u.name renders Welcome, Rohit and the nested @if (u.admin) adds the Admin link.
  4. The whole chain compiles to a single instruction: ɵɵconditional(ctx.loading() ? 0 : (tmp_0_0 = ctx.user()) ? 1 : 2, tmp_0_0). Angular evaluates it, gets a branch index, and only touches the DOM if the index changed. tmp_0_0 is how as u passes the value into branch 1 without calling user() twice.
  5. The component has no imports for any of this. The compiled component definition has no dependencies entry for control flow — it's built into the template instructions.
AdvancedStrict templates: narrowing signal reads
Advanced@else if with its own alias

@else if can have its own alias too — @else if (draft(); as d) compiles and works in Angular 22.

#*ngIf and ng-template

Before Angular 17's control flow blocks, conditionals were the NgIf structural directive. The * in *ngIf is syntactic sugar. The compiler rewrites an element with a * attribute into an <ng-template> wrapping that element, and moves the directive onto the template:

<p *ngIf="user">…</p> becomes <ng-template [ngIf]="user"><p>…</p></ng-template>

An <ng-template> renders nothing by itself — it's a blueprint of DOM (see ng-container vs ng-template). NgIf receives that blueprint as a TemplateRef, and when its input is truthy it stamps out one copy into the DOM next to the template's anchor; when it goes falsy, it destroys the copy.

welcome-old.ts
@Component({
  selector: 'app-welcome-old',
  imports: [NgIf, AsyncPipe],
  template: `
    <div>
      <p *ngIf="user; else signIn">Welcome, {{ user.name }}</p>
      <ng-template #signIn><p>Please sign in</p></ng-template>
    </div>

    <div>
      <ng-template [ngIf]="user" [ngIfElse]="signIn2">
        <p>Welcome, {{ user!.name }}</p>
      </ng-template>
      <ng-template #signIn2><p>Please sign in</p></ng-template>
    </div>

    <button (click)="logIn()">Sign in</button>

    <div>
      <p *ngIf="user$ | async as u">Hi {{ u.name }}</p>
    </div>
  `,
})
export class WelcomeOld {
  user: User | null = null;
  user$: Observable<User> = of({ name: 'Rohit' });

  logIn() {
    this.user = { name: 'Rohit' };
  }
}
What’s happening
  1. *ngIf="user; else signIn" is a microsyntax string. Angular splits it into two inputs on NgIf: ngIf = user and ngIfElse = signIn. #signIn is a template reference to the second <ng-template>, so ngIfElse receives a TemplateRef to render when the condition is falsy.
  2. The second <div> is the desugared form, written out by hand. The compiled output is identical for both: ɵɵtemplate(…) creates the template slot, and ɵɵproperty("ngIf", ctx.user)("ngIfElse", signIn_r3) sets the directive's inputs. Both render <p>Please sign in</p> while user is null.
  3. Clicking "Sign in" runs logIn(). It's a template event, so the component is checked; NgIf sees its ngIf input change to an object, destroys the else view, and creates the main one. Both divs now show Welcome, Rohit.
  4. *ngIf="user$ | async as u" was the standard Angular 16 pattern for observables: the async pipe subscribes, and as u exposes the emitted value as u. It renders Hi Rohit. The @if equivalent is @if (user$ | async; as u) — note the semicolon.
  5. In the first <div>, {{ user.name }} compiles even though user can be null. NgIf declares a static ngTemplateGuard_ngIf that tells the type checker to narrow the bound expression inside the template — the same narrowing @if gives you natively.
AdvancedCompiled *ngIf template instructions

The compiled template for the first <div> (development build, trimmed):

WelcomeOld_Template (compiled, trimmed)
// create
ɵɵtemplate(1, WelcomeOld_p_1_Template, 2, 1, "p", 3)
          (2, WelcomeOld_ng_template_2_Template, 2, 0, "ng-template", null, 0, ɵɵtemplateRefExtractor);
// update
const signIn_r3 = ɵɵreference(3);
ɵɵproperty("ngIf", ctx.user)("ngIfElse", signIn_r3);
// …
// component definition
dependencies: [NgIf, AsyncPipe]
What’s happening
  1. ɵɵtemplate(1, WelcomeOld_p_1_Template, …, "p", …) is the <ng-template> the * created; its content (the <p>) became its own little template function, WelcomeOld_p_1_Template.
  2. ɵɵtemplateRefExtractor turns #signIn into a TemplateRef value that can be passed around.
  3. ɵɵproperty("ngIf", …) is an ordinary property binding — to the NgIf directive's input. The conditional logic lives in NgIf's class, not in the compiler.
  4. dependencies: [NgIf, AsyncPipe] is why the import matters: no NgIf in imports, no directive to match.
AdvancedPitfall: missing NgIf import

#@if vs *ngIf

Both conditionally create and destroy DOM. The differences are in how they're built and how they type-check:

@if (Angular 17+ control flow)*ngIf (NgIf directive)
ImportNone — built into the compilerNgIf or CommonModule in imports
Else branches@else if (…) and @else { } inline; else tplRef + a separate <ng-template #tplRef>
Else-if chainsNativeNested ng-templates
Alias@if (x; as v)*ngIf="x as v"
Wrapper elementNone needed — blocks wrap any contentNeeds a host element or <ng-container>
Compiled toɵɵconditional(index) — no directive instanceɵɵtemplate + an NgIf instance per use
Status in Angular 22The recommended way@deprecated 20.0, "intent to remove in a future major release"

Performance-wise, @if skips creating a directive instance and running its input-change logic for every conditional, and evaluates the whole @if/@else if/@else chain as one expression. For a single condition the difference is small; it matters in large lists where each row has several conditionals.

To migrate an existing project there's a schematic: ng generate @angular/core:control-flow (alias of control-flow-migration in @angular/core). Run on this chapter's old-style examples, it rewrote *ngIf="user; else signIn" + the <ng-template #signIn> into an @if (user) { … } @else { … }, turned *ngIf="user$ | async as u" into @if (user$ | async; as u), and removed NgIf from imports.

#@for and track

@for renders its block once per item of an iterable. The interesting part is not the first render — it's what happens when the list changes. Recreating every row on every change would throw away DOM nodes, child component state and focus. So Angular diffs the old list against the new one and keeps, moves, creates or destroys rows individually.

To do that it needs to know which new item corresponds to which old row. That's the track expression: for each item it computes a key, and rows with the same key are the same row. track is mandatory — leave it out and the build fails with NG5002: @for loop must have a "track" expression.

todo-list.ts
export interface Todo {
  id: number;
  title: string;
  done: boolean;
}

@Component({
  selector: 'app-todo-list',
  imports: [NgFor],
  template: `
    <label><input type="checkbox" (change)="hideDone.set(!hideDone())" /> Hide done</label>

    <ul>
      @for (todo of visible(); track todo.id) {
        <li>{{ todo.title }}</li>
      }
    </ul>

    <ul>
      @for (todo of visible(); track todo) {
        <li>{{ todo.title }}</li>
      }
    </ul>

    <ul>
      <li *ngFor="let todo of visible(); trackBy: trackById; let i = index">{{ i + 1 }}. {{ todo.title }}</li>
    </ul>
  `,
})
export class TodoList {
  todos = signal<Todo[]>([
    { id: 1, title: 'Buy milk', done: false },
    { id: 2, title: 'Walk dog', done: true },
    { id: 3, title: 'Write notes', done: false },
  ]);
  hideDone = signal(false);
  visible = computed(() => (this.hideDone() ? this.todos().filter((t) => !t.done) : this.todos()));

  trackById(index: number, todo: Todo) {
    return todo.id;
  }

  // An immutable update: same ids, brand-new objects.
  markAllDone() {
    this.todos.update((list) => list.map((t) => ({ ...t, done: true })));
  }
}
What’s happening
  1. track todo.id compiles to a function ($index, $item) => $item.id. track todo compiles to Angular's built-in ɵɵrepeaterTrackByIdentity — the key is the object reference itself.
  2. markAllDone() replaces every object with a copy ({ ...t, done: true }) — the normal immutable signal update. The test grabs the <li> nodes before and after. With track todo.id: [true, true, true] — the same three DOM nodes, just re-bound. With track todo: [false, false, false] — the new objects have new identities, so Angular destroyed all three rows and built three new ones.
  3. Angular noticed and warned in the console: NG0956: The configured tracking expression (track by identity) caused re-creation of the entire collection of size 3. This is an expensive operation requiring destruction and subsequent creation of DOM nodes, directives, components etc.
  4. Reversing the list with track todo.id moves nodes instead of recreating them: the old third <li> is now first, and the old first is now third.
  5. visible is a computed() — from Rohit's "Computed and learnings" note. The filtering runs only when todos or hideDone changes, never on unrelated checks (see Avoid heavy work in templates). Ticking "Hide done" removes Walk dog; the two remaining <li>s are the original nodes.
  6. The old *ngFor needs a trackBy method that takes (index, item) and returns the key. With trackBy: trackById, markAllDone() kept all three nodes, same as track todo.id. Without trackBy, NgFor tracks by identity — the track todo behaviour.
AdvancedPicking a stable track expression
AdvancedMigrating *ngFor and trackBy

*ngFor is @deprecated 20.0 in Angular 22, like *ngIf. When the control-flow schematic converts it, trackBy: trackById becomes track trackById(i, todo), and an *ngFor with no trackBy becomes track t — identity tracking. Review those afterwards and switch them to an id.

#Why not track $index

Rohit's note says "Important, why not track $index". The reason: $index is the item's position, not the item. With track $index, row 0 is "whatever is at position 0" — so when items are inserted, removed or reordered, the rows stay where they are and the data slides through them. Anything a row holds that isn't bound from the item — a child component's signal, text typed into an <input>, focus, an animation — stays with the position and ends up attached to the wrong item.

index-bug.ts
@Component({
  selector: 'app-todo-row',
  template: `
    <span>{{ title() }}</span>
    <button (click)="editing.set(true)">edit</button>
    @if (editing()) {
      <em>editing</em>
    }
    <input placeholder="note" />
  `,
})
export class TodoRow {
  title = input.required<string>();
  editing = signal(false);
}

@Component({
  selector: 'app-index-bug',
  imports: [TodoRow],
  template: `
    <div>
      @for (todo of todos(); track $index) {
        <app-todo-row [title]="todo.title" />
      }
    </div>
    <div>
      @for (todo of todos(); track todo.id) {
        <app-todo-row [title]="todo.title" />
      }
    </div>
  `,
})
export class IndexBug {
  todos = signal<Todo[]>([
    { id: 1, title: 'Buy milk', done: false },
    { id: 2, title: 'Walk dog', done: false },
    { id: 3, title: 'Write notes', done: false },
  ]);

  removeFirst() {
    this.todos.update((list) => list.slice(1));
  }
}
What’s happening
  1. Both lists start the same. In each, the test clicks "edit" on the first row and types for Rohit into its note: Buy milk [editing] note="for Rohit", Walk dog, Write notes. The editing signal lives in that row's TodoRow instance; the note lives in that row's <input> DOM node.
  2. removeFirst() deletes Buy milk. With track $index, Angular compares keys: before [0, 1, 2], after [0, 1]. Keys 0 and 1 still exist, so it keeps rows 0 and 1 and just updates their title inputs ('Walk dog', 'Write notes'). Key 2 is gone, so it destroys the last row.
  3. Result with $index: Walk dog [editing] note="for Rohit", Write notes. Walk dog now claims to be in edit mode and shows Buy milk's note — the bug. The test confirms which pieces survived: DOM rows [true, true, false] and component instances [true, true, false] — the last row was destroyed, not the one the user deleted.
  4. With track todo.id: keys before [1, 2, 3], after [2, 3]. Key 1 is gone, so Angular destroys Buy milk's row — instance, signal, input and all — and leaves the other two untouched. Result: Walk dog, Write notes, no stray state. Instances survived: [false, true, true].
  5. Note that $index also did more work: it re-bound every remaining row's inputs, because every row's data changed. With a real id, only one row was touched. On a 1,000-row list, deleting the first item means 999 input updates with $index vs one removal with an id.
AdvancedWhen track $index is acceptable

When track $index is acceptable: the list never changes order or length while displayed, or the rows are dumb (plain text, no inputs, no child components with state). For primitive lists with duplicate values, where there's no id, $index is the pragmatic choice — the static grid in the next topic uses it.

#@for variables and @empty

Inside a @for block Angular provides implicit variables describing the current iteration. You can use them directly or alias them with let:

VariableMeaning
$indexPosition of the current item, from 0
$countLength of the collection
$first / $lastIs this the first / last item
$even / $oddIs $index even / odd

@empty is an optional block right after @for that renders when the collection has no items — from Rohit's notes, "Nice addition to @for is @empty".

for-vars.ts
@Component({
  selector: 'app-for-vars',
  imports: [NgFor],
  template: `
    <ul>
      @for (item of items(); track item; let i = $index, last = $last) {
        <li [class.odd]="$odd" [class.last]="last">
          {{ i + 1 }}/{{ $count }} {{ item }}@if ($first) { (first)}
        </li>
      } @empty {
        <li>No items</li>
      }
    </ul>

    <ul>
      <li *ngFor="let item of items(); let i = index; let odd = odd; let last = last; let c = count"
          [class.odd]="odd" [class.last]="last">{{ i + 1 }}/{{ c }} {{ item }}</li>
    </ul>

    <table>
      @for (row of grid; track $index; let r = $index) {
        <tr>
          @for (cell of row; track $index) {
            <td>{{ r }},{{ $index }}={{ cell }}</td>
          }
        </tr>
      }
    </table>
  `,
})
export class ForVars {
  items = signal(['apple', 'banana', 'cherry']);
  grid = [
    ['a', 'b'],
    ['c', 'd'],
  ];
}
What’s happening
  1. let i = $index, last = $last creates aliases; the un-aliased $odd, $count and $first are still usable. The three rows render 1/3 apple (first), 2/3 banana with class odd, and 3/3 cherry with class last. track item is fine here: the items are unique strings.
  2. The *ngFor version gets the same values from NgForOf's context, but every one has to be pulled out explicitly with let x = … — and the names have no $ (index, odd, last, count). Output: 1/3 apple, odd on banana, last on cherry.
  3. In the nested loop, $index inside the inner @for refers to the inner loop. To read the outer row's index you must alias it in the outer loop (let r = $index). The cells render 0,0=a, 0,1=b, 1,0=c, 1,1=d.
  4. items.set([]): the @for renders its @empty block, <li>No items</li>. The *ngFor list renders nothing but its anchor comment — the old way needed a separate *ngIf="items.length === 0".

#@switch

@switch picks one of several blocks by comparing a value against each @case. It's the template version of a JavaScript switch, with two important differences: there's no fall-through (no break needed — only the matching block renders), and comparison is strict ===.

status-view.ts
type Status = 'idle' | 'loading' | 'success' | 'error';

@Component({
  selector: 'app-status',
  imports: [NgSwitch, NgSwitchCase, NgSwitchDefault],
  template: `
    @switch (status()) {
      @case ('loading') {
        <p>Loading…</p>
      }
      @case ('success') {
        <p>Saved!</p>
      }
      @case ('error') {
        <p>Something went wrong</p>
      }
      @default {
        <p>Ready</p>
      }
    }

    @let s = status();
    @switch (s) {
      @case ('idle')
      @case ('loading') {
        <p>Busy or idle</p>
      }
      @case ('success') {
        <p>Saved!</p>
      }
      @case ('error') {
        <p>Failed</p>
      }
      @default never;
    }

    <div [ngSwitch]="status()">
      <p *ngSwitchCase="'loading'">Loading…</p>
      <p *ngSwitchCase="'success'">Saved!</p>
      <p *ngSwitchDefault>Ready</p>
    </div>

    @switch (code()) {
      @case (1) { <p>number one</p> }
      @default { <p>no match</p> }
    }
  `,
})
export class StatusView {
  status = signal<Status>('idle');
  code = signal<number | string>('1');
}
What’s happening
  1. With status 'idle', no @case matches the first switch, so @default renders Ready. Setting 'loading' renders Loading…; 'error' renders Something went wrong. Each change destroys the old block and creates the new one.
  2. The second switch shows two newer features (both compile and run in Angular 22). Consecutive @cases with no body share the next body: 'idle' and 'loading' both render Busy or idle. And @default never; asks the type checker to prove every member of the union has a case.
  3. Remove the @case ('error') and the build fails: TS2322: Type '"error"' is not assignable to type 'never'. That's the same exhaustiveness trick as const x: never = value in a TypeScript switch.
  4. Why @let s = status() first? The type checker generates switch (status()) { … default: const x: never = status(); }, and TypeScript can't narrow a second call. With @switch (status()) and @default never;, even a complete set of cases fails with Type 'Status' is not assignable to type 'never'. Reading the signal into @let gives TypeScript a constant it can narrow.
  5. The [ngSwitch] version is the Angular 16 way: an attribute directive on the container plus a structural *ngSwitchCase per case. For 'error' it has no case, so it shows Ready. All three directives are @deprecated 20.0.
  6. The last switch shows strict equality: code is the string '1', so @case (1) doesn't match and no match renders. After code.set(1) it shows number one. Route params and form values are strings — convert before switching.

#One structural directive per element

From Rohit's notes: you can't put two structural directives on the same element. Each * means "wrap this element in an <ng-template> and hand it to this directive". With two of them, which one is the outer template and which the inner? Angular doesn't guess.

todo-list.html (Angular 16)
<ul>
  <li *ngFor="let t of todos" *ngIf="!t.done">{{ t.title }}</li>
</ul>
What’s happening
  1. The build fails with NG5002: Can't have multiple template bindings on one element. Use only one attribute prefixed with *.
  2. It also reports TS2339: Property 't' does not exist on type '…' for t.done — once the second * is rejected, t isn't declared anywhere the *ngIf can see it.
  3. Even if it were allowed, the order matters: *ngIf outside *ngFor means "show the whole list or nothing", *ngIf inside means "filter each item". The rule forces you to say which one you meant.
AdvancedFix: ng-container or nested blocks

The fix is to move one directive onto an element that adds nothing to the DOM: <ng-container>. Or, in Angular 22, just nest blocks — @for and @if aren't attributes, so the restriction doesn't exist.

containers.ts
@Component({
  selector: 'app-containers',
  imports: [NgFor, NgIf],
  template: `
    <ul>
      @for (t of todos; track t.id) {
        @if (!t.done) {
          <li>{{ t.title }}</li>
        }
      }
    </ul>

    <ul>
      <ng-container *ngFor="let t of todos">
        <li *ngIf="!t.done">{{ t.title }}</li>
      </ng-container>
    </ul>
  `,
})
export class Containers {
  todos = [
    { id: 1, title: 'Buy milk', done: false },
    { id: 2, title: 'Walk dog', done: true },
    { id: 3, title: 'Write notes', done: false },
  ];
}
What’s happening
  1. Modern version: the @for creates one view per todo; inside each, @if creates the <li> only for unfinished ones. The <ul> gets <li>Buy milk</li> and <li>Write notes</li>, plus anchor comments.
  2. Old version: *ngFor moves onto <ng-container>, which renders no element — only a <!--ng-container--> comment. *ngIf stays on the <li>. The <ul> gets the same two <li>s, so CSS like ul > li and the HTML rule that <ul> only contains <li> still hold.
  3. Using a <div> instead of <ng-container> would have worked too, but would have put <div>s inside the <ul> — invalid HTML and broken styling. That's the whole reason <ng-container> exists.
  4. Better still for filtering: do it in a computed() (activeTodos = computed(() => todos().filter(t => !t.done))) and loop over that, so the template has no per-row condition at all.

#ng-container vs ng-template

These two look alike and are constantly confused. The difference is about when their content is rendered:

  • <ng-container> is a grouping element that renders its children immediately, in place, without adding an element of its own. Think of it as "a <div> that isn't there" — a place to hang a structural directive or group nodes.
  • <ng-template> is a blueprint. Its content is compiled but not rendered at all — until something explicitly instantiates it: a structural directive (*ngIf's desugaring), ngTemplateOutlet, or code using ViewContainerRef.createEmbeddedView. It can be rendered zero, one or many times, each time with a different context.
container-vs-template.ts
@Component({
  selector: 'app-container-vs-template',
  imports: [NgTemplateOutlet],
  template: `
    <div><ng-container><p>shown</p></ng-container></div>
    <div><ng-template><p>never shown</p></ng-template></div>
    <div>
      <ng-template #greet let-name>Hello {{ name }}</ng-template>
      <ng-container *ngTemplateOutlet="greet; context: { $implicit: 'Rohit' }" />
      <ng-container [ngTemplateOutlet]="greet" [ngTemplateOutletContext]="{ $implicit: 'Ravi' }" />
    </div>
  `,
})
export class ContainerVsTemplate {}
What’s happening
  1. The first <div> renders <p>shown</p><!--ng-container-->: the paragraph appears, and the container itself left only a comment.
  2. The second <div> renders <!--container--> and nothing else. The <p>never shown</p> exists only as a compiled template function; nobody asked for it, so it's never created.
  3. In the third <div>, #greet captures the template as a TemplateRef. let-name declares a template input variable; with no value it binds to the context's $implicit property.
  4. *ngTemplateOutlet="greet; context: { $implicit: 'Rohit' }" renders the template once with that context → Hello Rohit. The second outlet is the same thing in non-* form → Hello Ravi. One blueprint, rendered twice with different data. The result: <!--container-->Hello Rohit<!--container-->Hello Ravi<!--ng-container-->.
Advancedng-container vs ng-template reference table
<ng-container><ng-template>
Renders its contentImmediately, in placeOnly when instantiated
Times renderedOnceZero to many
Adds a DOM elementNo (a comment anchor)No (a comment anchor)
Typical useHost for *ngIf/*ngFor, grouping, *ngTemplateOutletelse branches, reusable fragments, passing templates to components
Can receive a contextNoYes — let-x variables, $implicit

With Angular 22 control flow you'll need <ng-container> far less (blocks don't need a host element), but <ng-template> is as important as ever: it's how you pass a chunk of template to a reusable component. That's covered in ng-template and Rendering templates with ngTemplateOutlet.

What ends up in the DOM?
HTML
<section>
  <ng-template #tpl><b>B</b></ng-template>
  <ng-container><i>I</i></ng-container>
  <ng-container *ngTemplateOutlet="tpl" />
  <ng-container *ngTemplateOutlet="tpl" />
</section>
Show answer

<i>I</i> once and <b>B</b> twice, plus comment anchors — no <ng-container> or <ng-template> elements. The real innerHTML: <!--container--><i>I</i><!--ng-container--><b>B</b><!--container--><b>B</b><!--container-->.

What’s happening
  1. <ng-template #tpl> renders nothing where it is written; it only defines tpl.
  2. The plain <ng-container> renders its child <i>I</i> immediately and leaves a <!--ng-container--> comment.
  3. Each *ngTemplateOutlet="tpl" creates one copy of the template's content, so <b>B</b> appears twice.
  4. Neither tag ever becomes a real element, so section > b matches both bold elements in CSS.

Built from Rohit’s “Angular interview prep” doc. Examples target Angular 22 and are compiled and tested with the Angular CLI and Vitest.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.