Angular NotesRohit’s interview study guide
Chapter 15

Content Projection & Templates

Building flexible components — ng-content slots, querying children with viewChild/contentChild, and rendering templates dynamically.

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

#Content projection with ng-content

Content projection lets a component accept markup from its parent and decide where it appears. The child's template has a <ng-content /> placeholder; whatever the parent writes between the child's opening and closing tags is moved into that spot. It's how you build wrappers — cards, dialogs, panels, layout shells — without the wrapper knowing what goes inside. React calls it children, Web Components call it slots.

The mental model that prevents most bugs: the parent builds the content, the child only places it. The projected nodes are created by the parent's template, bound to the parent's data, checked as part of the parent's view, and destroyed with the parent. <ng-content> doesn't create anything — it's a hole in the child's template that the parent's already-built nodes are slotted into. It doesn't even exist in the DOM.

card.ts
@Component({
  selector: 'app-card',
  template: `
    <div class="card">
      <ng-content />
    </div>
  `,
})
export class Card {}

@Component({
  selector: 'app-card-demo',
  imports: [Card],
  template: `
    <app-card>
      <h2>Ada Lovelace</h2>
      <p>First programmer.</p>
    </app-card>
  `,
})
export class CardDemo {}
AdvancedRendered DOM for basic projection
Rendered DOM (from the test)
<app-card><div class="card"><h2>Ada Lovelace</h2><p>First programmer.</p></div></app-card>
What’s happening
  1. CardDemo's template is compiled with <h2> and <p> as its own nodes. When it's created, Angular creates both elements — they belong to CardDemo, not to Card.
  2. Card's template has a <div class="card"> with <ng-content /> inside. At creation time Angular moves CardDemo's two nodes into that position.
  3. The rendered DOM has no <ng-content> element — it's purely a compile-time placeholder. The <h2> and <p> sit directly inside the div.
  4. If CardDemo bound {{ name }} inside the <h2>, that expression would read CardDemo's name, and it would update when CardDemo's view is checked. Card can't see or change it — it only owns the frame.
AdvancedCollapsible card with hidden ng-content

The "parent builds it" rule has a consequence that catches everyone: hiding <ng-content> doesn't stop the content from being created.

card.ts (collapsible)
@Component({ selector: 'app-heavy', template: `heavy!` })
export class Heavy implements OnDestroy {
  constructor() { created.push('Heavy constructed'); }
  ngOnDestroy() { created.push('Heavy destroyed'); }
}

@Component({
  selector: 'app-collapsible',
  template: `
    <button (click)="open.set(!open())">toggle</button>
    @if (open()) {
      <ng-content />
    }
  `,
})
export class Collapsible {
  open = signal(false);
}

@Component({
  selector: 'app-collapsible-demo',
  imports: [Collapsible, Heavy],
  template: `
    <app-collapsible>
      <app-heavy />
    </app-collapsible>
  `,
})
export class CollapsibleDemo {}
AdvancedProjected content exists before it is shown
Vitest output
Heavy constructed
rendered, closed: <app-collapsible><button>toggle</button></app-collapsible>
open: <app-collapsible><button>toggle</button><app-heavy>heavy!</app-heavy></app-collapsible>
closed again: <app-collapsible><button>toggle</button></app-collapsible>
open again
Heavy destroyed
fixture destroyed
What’s happening
  1. CollapsibleDemo is created, and its creation pass constructs Heavy straight away — before anyone has looked at open(). The first log line is Heavy constructed, while the collapsible is closed and <app-heavy> is nowhere in the DOM.
  2. Clicking toggle sets open to true. The @if block is created and <ng-content /> inside it attaches the existing Heavy instance. No second constructor call.
  3. Closing again removes the @if block, which detaches <app-heavy> from the DOM — but doesn't destroy it. There's no Heavy destroyed line, and opening again re-attaches the same instance.
  4. Heavy is only destroyed when its owner, CollapsibleDemo, is destroyed (fixture.destroy()).
  5. So @if around <ng-content> controls visibility, not existence. If the content is expensive (makes HTTP calls in ngOnInit, renders a big chart) and should only be created when shown, the child must take a template instead and create it on demand — see ng-template and Template-driven components: putting it together.
AdvancedProjecting the same content twice

A related rule: each projected node can be in one place only. You can't print the same content twice by writing <ng-content /> twice.

card.ts (twice)
@Component({
  selector: 'app-twice',
  template: `<section>A: <ng-content /></section><section>B: <ng-content /></section>`,
})
export class Twice {}
// parent: <app-twice><b>hello</b></app-twice>
AdvancedRendered DOM: last ng-content wins
Rendered DOM
<app-twice><section>A: </section><section>B: <b>hello</b></section></app-twice>
What’s happening
  1. The parent created exactly one <b> node. A DOM node can only have one parent, so Angular can't put it in both sections.
  2. With two catch-all <ng-content />s, the content went to the last one (B) in this Angular 22 test, and A stayed empty. Angular doesn't warn about this.
  3. To render the same thing several times (in a list, for example), the child needs a template it can stamp out repeatedly — that's ngTemplateOutlet.
AdvancedPitfall: attributes on ng-content

#Multiple projection slots

A single <ng-content /> takes everything. To build a component with distinct areas — a title, a body, action buttons — give each <ng-content> a select attribute containing a CSS selector. Each node the parent passes in goes to the first slot whose selector it matches; anything that matches no selector goes to the slot without select (the default slot). If there's no default slot, unmatched content is dropped.

The mental model is a mail sorter: each slot is a pigeonhole labelled with a selector, and the default slot is "everything else".

dialog.ts
@Component({
  selector: 'app-dialog',
  template: `
    <header><ng-content select="[dialog-title]" /></header>
    <main><ng-content /></main>
    <footer>
      <ng-content select=".actions, app-dialog-actions">
        <button>Close</button>
      </ng-content>
    </footer>
  `,
})
export class Dialog {}

@Component({
  selector: 'app-dialog-demo',
  imports: [Dialog],
  template: `
    <app-dialog>
      <p>Are you sure you want to delete 3 files?</p>
      <h2 dialog-title>Delete files</h2>
      <div class="actions"><button>Cancel</button><button>Delete</button></div>
      <p>This cannot be undone.</p>
    </app-dialog>

    <app-dialog>
      <h2 dialog-title>Saved</h2>
      <p>Your changes were saved.</p>
    </app-dialog>

    <app-dialog>
      <ng-container ngProjectAs="[dialog-title]">Wrapped title</ng-container>
      @if (true) {
        <h2 dialog-title>Inside an if block</h2>
      }
    </app-dialog>
  `,
})
export class DialogDemo {}
AdvancedSlots, fallback content and ngProjectAs
Rendered DOM (one line per dialog)
<app-dialog><header><h2 dialog-title="">Delete files</h2></header><main><p>Are you sure you want to delete 3 files?</p><p>This cannot be undone.</p></main><footer><div class="actions"><button>Cancel</button><button>Delete</button></div></footer></app-dialog>
<app-dialog><header><h2 dialog-title="">Saved</h2></header><main><p>Your changes were saved.</p></main><footer><button>Close</button></footer></app-dialog>
<app-dialog><header>Wrapped title<h2 dialog-title="">Inside an if block</h2></header><main></main><footer><button>Close</button></footer></app-dialog>
What’s happening
  1. Dialog 1. Angular sorts the four top-level nodes by selector. The <h2 dialog-title> matches [dialog-title] and goes to the header, even though it was written second. The div.actions matches .actions and goes to the footer. Both <p>s match nothing, so they go to the default slot in <main>, in the order the parent wrote them. Where things appear is decided by the child's template, not by the parent's order.
  2. The select value is a CSS selector, and a comma list works: .actions, app-dialog-actions accepts either. Keep it to element, attribute and class selectors. Matching happens against the top-level nodes the parent wrote, not their descendants: in another test, a <div><h2>…</h2></div> passed to a component with only <ng-content select="h2" /> was not matched, and because that component had no default slot, the div (and a stray <p>) were dropped from the DOM entirely.
  3. Dialog 2 passes no actions. The footer slot has fallback content between its tags (<button>Close</button>), and Angular renders that when nothing was projected into the slot. Dialog 1 projected actions, so its fallback was not shown.
  4. Dialog 3, ngProjectAs. An <ng-container> has no attributes for a selector to match, so ngProjectAs="[dialog-title]" tells Angular to treat it as if it matched. Its text lands in the header.
  5. Dialog 3, @if. An @if block whose content has a single root element is matched using that element, so the <h2 dialog-title> inside it goes to the header too. Nothing went to the default slot, so <main> is empty.
AdvancedMulti-root blocks fall into the default slot

If a control-flow block has more than one root node, Angular can't match it to a specific slot and puts the whole block in the default slot. The build tells you so — this is the real warning from ng test when the @if above had a second <p> in it:

Angular compiler warning
▲ [WARNING] NG8011: Node matches the "[dialog-title]" slot of the "Dialog" component, but will not be projected into the specific slot because the surrounding @if has more than one node at its root. To project the node in the right slot, you can:
1. Wrap the content of the @if block in an <ng-container/> that matches the "[dialog-title]" selector.
2. Split the content of the @if block across multiple @if blocks such that each one only has a single projectable node at its root.
3. Remove all content from the @if block, except for the node being projected.
AdvancedLimitation: slots are assigned once

#viewChild()

viewChild() gives a component a reference to something in its own template — an element, a child component, a directive, a TemplateRef. It's the declarative replacement for document.querySelector, scoped to this component's view. In Angular 22 it's a function that returns a signal; the older form is the @ViewChild() decorator on a property.

You can locate the target by a template reference variable name (viewChild('query') finds #query) or by a type (viewChild(Stopwatch) finds the <app-stopwatch> instance). The result depends on what's there: an ElementRef for a plain element, the component instance for a component. The read option asks for something else at the same spot — say, the ElementRef of a component's host element, or a ViewContainerRef.

The mental model: a query is a live pointer into your view. As a signal, it updates itself when @if/@for create or remove the element, so anything derived from it with computed() updates too.

queries.ts
@Component({
  selector: 'app-search-box',
  imports: [Stopwatch],
  template: `
    <input #query placeholder="Search…" />
    <app-stopwatch />
    @if (showHelp()) { <p #help>Type at least 3 letters</p> }
    @for (tag of tags(); track tag) { <span #tag class="tag">{{ tag }}</span> }
  `,
})
export class SearchBox {
  query = viewChild.required<ElementRef<HTMLInputElement>>('query');
  help = viewChild<ElementRef<HTMLParagraphElement>>('help');
  stopwatch = viewChild.required(Stopwatch); // query by component class
  stopwatchEl = viewChild.required(Stopwatch, { read: ElementRef }); // its host element instead
  tagEls = viewChildren<ElementRef<HTMLSpanElement>>('tag');

  showHelp = signal(false);
  tags = signal(['angular', 'signals']);
  tagCount = computed(() => this.tagEls().length);

  constructor() {
    afterNextRender(() => this.query().nativeElement.focus());
  }
}
AdvancedviewChild results at each stage
Vitest output
focused: true
help before: undefined
help after: Type at least 3 letters
stopwatch is instance: _Stopwatch, el: APP-STOPWATCH
tagCount: 2
tagCount after push: 3
What’s happening
  1. viewChild.required(...) says "this element is always there". Its type is Signal<ElementRef<HTMLInputElement>> (no undefined), so this.query().nativeElement needs no null check. It's for elements outside any @if.
  2. afterNextRender focuses the input once the whole app has rendered — the DOM is guaranteed to exist and it won't run on the server. After the first render, document.activeElement is the input → focused: true.
  3. help is a plain viewChild because its <p> is inside @if. While showHelp() is false it returns undefined. After showHelp.set(true) and a render, the same signal returns the new element. No hook, no manual refresh.
  4. viewChild.required(Stopwatch) returns the component instance (_Stopwatch is how the test build printed the class name), while { read: ElementRef } on the same locator returns its host element, <app-stopwatch>.
  5. viewChildren('tag') returns a Signal<readonly ElementRef[]> with one entry per @for row. tagCount is a computed over it: 2, and after tags.update(t => [...t, 'rxjs']) it's 3 — it re-derived because the query result changed.
AdvancedOlder syntax: @ViewChild decorators

The decorator form you'll see in older code, side by side:

Angular 22 (signal query)
query = viewChild.required<ElementRef>('query');
help = viewChild<ElementRef>('help');
tags = viewChildren<ElementRef>('tag');

// read anywhere, it's a signal:
this.query().nativeElement.focus();
Angular 16 (decorator)
@ViewChild('query') query!: ElementRef;
@ViewChild('help') help?: ElementRef;
@ViewChildren('tag') tags!: QueryList<ElementRef>;

// only reliable from ngAfterViewInit on:
ngAfterViewInit() { this.query.nativeElement.focus(); }
What’s happening
  1. The decorator version is a plain property that Angular assigns at specific points of change detection; before ngAfterViewInit it's undefined (unless { static: true }). The ! is a lie to the type checker.
  2. @ViewChildren gives a QueryList, which you watch with .changes (an Observable) to react to @for rows being added. The signal version is just an array inside a signal, so computed() and effect() work on it directly.
  3. The signal version is typed honestly (required vs optional) and has no "too early" window for static elements — Content vs view has a test showing exactly when each kind can see what.
AdvancedError: NG0951 for a missing required query

#Querying a child component

From my notes: "Querying child component using viewChild !!!!". When the locator is a component class, viewChild() gives you the child component instance, so the parent can read its signals and call its public methods. It's the escape hatch for imperative APIs that don't fit inputs and outputs well: player.play(), form.reset(), stepper.next(), stopwatch.start().

queries.ts (Stopwatch and how SearchBox uses it)
@Component({
  selector: 'app-stopwatch',
  template: `<span>{{ seconds() }}s</span>`,
})
export class Stopwatch {
  seconds = signal(0);
  running = signal(false);
  start() { this.running.set(true); }
  tick() { if (this.running()) this.seconds.update((s) => s + 1); }
  reset() { this.running.set(false); this.seconds.set(0); }
}

// in SearchBox:
stopwatch = viewChild.required(Stopwatch);

startTimer() {
  this.stopwatch().start();
  this.stopwatch().tick();
}
AdvancedCalling child methods through viewChild
Vitest output
stopwatch DOM: 1s
What’s happening
  1. viewChild.required(Stopwatch) finds the <app-stopwatch> in SearchBox's template and returns the instance Angular created for it. required is right because it's not inside an @if.
  2. startTimer() calls two public methods on the child. start() sets the child's running signal; tick() sees running() is true and updates seconds 0 → 1.
  3. Because seconds is a signal read by the child's template, the write marks the child's view for refresh, and the next render shows 1s. Even though the parent made the call, the child re-renders itself.
  4. If Stopwatch used a plain field (seconds = 0; tick() { this.seconds++; }), the call would change the number but not the screen: the child is OnPush (the Angular 22 default) and nothing marked it dirty. Imperative calls into OnPush children need signals or markForCheck() inside the child (see OnPush).
AdvancedTemplate reference to a child component

If you only need the child in the template, you don't need a query at all — a template reference variable on a component refers to its instance:

refvar template (tested)
<app-stopwatch #sw />
<button (click)="sw.start(); sw.tick()">start</button>
What’s happening
  1. #sw on a component element binds the component instance (on a plain element it would be the DOM element).
  2. The click handler calls the child's methods directly. After the click and a render, the test's DOM text was 1sstart — the stopwatch shows 1s, followed by the button's text.
  3. Use this when the interaction stays in the template; use viewChild() when the class needs the instance (in a method, a computed, or a render hook).

#contentChild()

contentChild() is the same idea as viewChild(), pointed at the content instead of the view: it finds things the parent projected between your tags. It's how a container component discovers its pieces — a tab group finding its tabs, an accordion its panels, a list its item template — without the parent wiring them up through inputs.

Same API shape: a locator (template reference name or type), optional read, .required, and contentChildren() for many. One difference matters: descendants. By default contentChildren() only looks at the direct children the parent wrote between your tags, while contentChild() searches all the way down. Both defaults are verified below.

content-queries.ts
@Component({
  selector: 'app-tab',
  template: `@if (active()) { <ng-content /> }`,
})
export class Tab {
  label = input.required<string>();
  active = input(false);
}

@Directive({ selector: '[appTabsHeader]' })
export class TabsHeader {}

@Component({
  selector: 'app-tabs',
  template: `
    <nav>
      @for (tab of tabs(); track tab) { <button>{{ tab.label() }}</button> }
    </nav>
    <ng-content />
    <small>{{ tabs().length }} direct tabs, {{ allTabs().length }} including nested</small>
  `,
})
export class Tabs {
  tabs = contentChildren(Tab); // descendants: false by default
  allTabs = contentChildren(Tab, { descendants: true });
  firstTab = contentChild(Tab);
  header = contentChild(TabsHeader); // descendants: true by default
  headerShallow = contentChild(TabsHeader, { descendants: false });
}

@Component({
  selector: 'app-tabs-demo',
  imports: [Tabs, Tab, TabsHeader],
  template: `
    <app-tabs>
      <div><h3 appTabsHeader>Account</h3></div>
      <app-tab label="Profile" [active]="true">Profile body</app-tab>
      <app-tab label="Settings">Settings body</app-tab>
      <div>
        <app-tab label="Nested">inside a div</app-tab>
      </div>
    </app-tabs>
  `,
})
export class TabsDemo {}
AdvancedcontentChildren with and without descendants
Vitest output
firstTab: Profile header: true shallow: false
<app-tabs><nav><button>Profile</button><button>Settings</button></nav><div><h3 apptabsheader="">Account</h3></div><app-tab label="Profile">Profile body</app-tab><app-tab label="Settings"></app-tab><div><app-tab label="Nested"></app-tab></div><small>2 direct tabs, 3 including nested</small></app-tabs>
What’s happening
  1. The parent (TabsDemo) creates all three Tab instances, because they're in its template. Tabs only queries them.
  2. contentChildren(Tab) with the default descendants: false finds the two <app-tab>s written directly between <app-tabs> tags — Profile and Settings. The Nested tab is wrapped in a <div>, so it's skipped: the nav shows two buttons and the counter says 2 direct tabs.
  3. { descendants: true } also searches inside that <div>: 3 including nested.
  4. contentChild(TabsHeader) finds the <h3 appTabsHeader> even though it's inside a <div> (header: true), because the singular query defaults to descendants: true. With { descendants: false } it doesn't (shallow: false). So the two defaults are opposite — a common source of "why is my query empty?".
  5. Tab puts its <ng-content /> inside @if (active()). The Settings tab's text node was still created by the parent, but it isn't attached, so <app-tab label="Settings"> is empty in the DOM. That's the rule from the first topic again.
  6. tabs is a signal, so the @for in the nav re-renders by itself if the parent adds or removes a tab with @if. In decorator code you'd subscribe to QueryList.changes for that.
AdvancedContent query timing

Timing, briefly (the full test is in Content vs view): content queries are resolved before view queries. Decorator @ContentChild is assigned by ngAfterContentInit, @ViewChild by ngAfterViewInit. Signal queries can be read any time and return whatever exists at that moment.

AdvancedReceiving a TemplateRef from the parent

#ng-template

From my notes: "Ng-Template". An <ng-template> is a blueprint: a chunk of template that Angular compiles but does not render. Nothing inside it is created — no elements, no components, no bindings evaluated — until some code instantiates it. Each instantiation creates an independent embedded view with its own DOM nodes and its own component instances.

The mental model: <ng-template> is a rubber stamp, not an impression. You can keep it, pass it around (as a TemplateRef), and press it as many times as you like into any ViewContainerRef — and every press is a fresh copy. All the structural features are built on it: @if and @for create embedded views from implicit templates, and the old *ngIf literally desugars to <ng-template [ngIf]> (see *ngIf and ng-template).

templates.ts
@Component({ selector: 'app-badge', template: `badge` })
export class Badge implements OnDestroy {
  constructor() { tplLog.push('Badge constructed'); }
  ngOnDestroy() { tplLog.push('Badge destroyed'); }
}

@Component({
  selector: 'app-lazy-demo',
  imports: [Badge],
  template: `
    <p>before</p>
    <ng-template #later><app-badge /> stamped</ng-template>
    <ng-container #outlet />
    <p>after</p>
  `,
})
export class LazyDemo {
  later = viewChild.required<TemplateRef<unknown>>('later');
  outlet = viewChild.required('outlet', { read: ViewContainerRef });

  stamp() {
    this.outlet().createEmbeddedView(this.later()); // a new view, inserted at the anchor
  }
  clear() {
    this.outlet().clear(); // destroys every view in this container
  }
}
AdvancedStamping templates with ViewContainerRef
Vitest output
initial: <p>before</p><p>after</p>
raw: <p>before</p><!--container--><!--ng-container--><p>after</p>
Badge constructed
Badge constructed
after 2 stamps: <p>before</p><app-badge>badge</app-badge> stamped<app-badge>badge</app-badge> stamped<p>after</p>
Badge destroyed
Badge destroyed
after clear
What’s happening
  1. Initial render: only the two <p>s are visible, and no Badge was constructed — the template's contents don't exist yet. The raw HTML shows what's really there: an <!--container--> comment where the <ng-template> was, and an <!--ng-container--> comment anchor for the <ng-container #outlet>.
  2. later is a viewChild of type TemplateRef — the stamp itself. outlet uses { read: ViewContainerRef } on the <ng-container>, which gives a view container: a place where views can be inserted, anchored at that comment.
  3. stamp() called twice runs createEmbeddedView twice. Each call instantiates the template: a new Badge (two Badge constructed lines) and a new " stamped" text node, inserted at the anchor — between the paragraphs.
  4. clear() destroys every view in the container, and each Badge gets ngOnDestroy. That's the contrast with projected content, which lives as long as its parent no matter where it's shown: with a template, whoever instantiates it owns the lifetime.
  5. If you inject ViewContainerRef in the component itself (inject(ViewContainerRef)) instead of querying an anchor, the container is anchored at the component's host element, and stamped views are inserted after the host, as its siblings — not inside it. A useful surprise to know about.
AdvancedWays a template gets instantiated

From my notes, "One of the way is like this" — the everyday ways a template gets instantiated, from most to least common:

  • @if (x) { … } @else { … } and @for — implicit templates, managed for you.
  • [ngTemplateOutlet] — render a TemplateRef you hold, with a context (next topic).
  • ViewContainerRef.createEmbeddedView(tpl, context) — full control from code (above).
  • The legacy *ngIf="x; else elseTpl", where elseTpl is a <ng-template #elseTpl>.
AdvancedRendering difference from ng-container

#Rendering templates with ngTemplateOutlet

From my notes: "NgTemplateOutlet (Easiest way to render template)". NgTemplateOutlet (from @angular/common) is a directive that says "render this TemplateRef here". It's the declarative version of createEmbeddedView: you bind [ngTemplateOutlet] to a template and Angular creates the embedded view at that spot, replaces it when you bind a different template, and destroys it when the outlet goes away.

Its partner is the context: an object whose properties become variables inside the template. The template declares them with let- attributes. let-name (no value) receives the special $implicit property; let-role="role" receives the role property. That turns a template into something like a function: the context is its arguments, and let- declarations are its parameter list.

templates.ts (outlet demo)
@Component({
  selector: 'app-outlet-demo',
  imports: [NgTemplateOutlet],
  template: `
    <ng-template #userRow let-name let-role="role" let-i="index">
      <li>{{ i + 1 }}. {{ name }} ({{ role }})</li>
    </ng-template>

    <ng-template #compact let-name><li>{{ name }}</li></ng-template>

    <ul>
      @for (u of users(); track u.name; let i = $index) {
        <ng-container
          [ngTemplateOutlet]="dense() ? compact : userRow"
          [ngTemplateOutletContext]="{ $implicit: u.name, role: u.role, index: i }" />
      }
    </ul>

    <ul class="guest">
      <ng-container *ngTemplateOutlet="userRow; context: { $implicit: 'Guest', role: 'viewer', index: 99 }" />
    </ul>
  `,
})
export class OutletDemo {
  users = signal([
    { name: 'Ada', role: 'admin' },
    { name: 'Linus', role: 'editor' },
  ]);
  dense = signal(false);
}
AdvancedContext variables and switching templates
Rendered DOM
<!-- dense = false -->
<ul><li>1. Ada (admin)</li><li>2. Linus (editor)</li></ul><ul class="guest"><li>100. Guest (viewer)</li></ul>
<!-- after dense.set(true) -->
<ul><li>Ada</li><li>Linus</li></ul><ul class="guest"><li>100. Guest (viewer)</li></ul>
What’s happening
  1. The two <ng-template>s render nothing on their own; #userRow and #compact are template reference variables holding their TemplateRefs, usable anywhere in this template.
  2. #userRow declares three variables: let-name (bound to $implicit), let-role="role" and let-i="index". Note the right-hand side is the context property name, not an expression.
  3. For each user, the @for renders an <ng-container> with the outlet. On the first row the context is { $implicit: 'Ada', role: 'admin', index: 0 }, so inside the template name is 'Ada', role is 'admin', i is 0 → <li>1. Ada (admin)</li>.
  4. [ngTemplateOutlet]="dense() ? compact : userRow" is an ordinary binding, so the template choice is data. After dense.set(true) the outlet sees a different TemplateRef, destroys the old embedded view and creates one from #compact, which only uses $implicit → <li>Ada</li>.
  5. The last ul uses the microsyntax form: *ngTemplateOutlet="userRow; context: {…}" is shorthand for [ngTemplateOutlet] plus [ngTemplateOutletContext]. The context's index: 99 makes it 100. Guest (viewer).
  6. <ng-container> is the usual host for the outlet because it adds no element of its own — the <li> from the template sits directly inside the <ul>.
AdvancedType safety: untyped context variables
AdvancedChoosing the injector with ngTemplateOutletInjector

Angular 22's NgTemplateOutlet also has a third input, ngTemplateOutletInjector. By default the embedded view uses the injector of the place where the template was declared. You can pass a specific Injector, or 'outlet' to inherit from where the outlet sits in the DOM (from the NgTemplateOutlet type definitions in @angular/common).

#Template-driven components: putting it together

Added

Now combine the pieces into the pattern behind every serious component library (Angular Material's table, CDK virtual scroll, PrimeNG's pTemplate): a component that owns the behaviour — looping, empty state, maybe sorting or virtual scrolling — and lets the parent supply the look of each item as a template.

Why templates and not <ng-content>? Because content is created once, by the parent, and can be placed only once. A list needs to render the parent's markup once per item, with that item's data, and create it only when needed. That's exactly what a template plus a context gives you.

data-list.ts
export interface ItemContext<T> {
  $implicit: T;
  index: number;
}

// Marker directive: finds the item template and types its context
@Directive({ selector: 'ng-template[appItem]' })
export class ItemTemplate<T> {
  appItem = input.required<readonly T[]>(); // only used to infer T
  constructor(public template: TemplateRef<ItemContext<T>>) {}

  static ngTemplateContextGuard<T>(dir: ItemTemplate<T>, ctx: unknown): ctx is ItemContext<T> {
    return true;
  }
}

@Directive({ selector: 'ng-template[appEmpty]' })
export class EmptyTemplate {
  constructor(public template: TemplateRef<unknown>) {}
}

@Component({
  selector: 'app-data-list',
  imports: [NgTemplateOutlet],
  template: `
    <ul>
      @for (item of items(); track $index; let i = $index) {
        <li>
          <ng-container
            [ngTemplateOutlet]="itemTpl()?.template ?? defaultItem"
            [ngTemplateOutletContext]="{ $implicit: item, index: i }" />
        </li>
      } @empty {
        <li class="empty">
          <ng-container [ngTemplateOutlet]="emptyTpl()?.template ?? defaultEmpty" />
        </li>
      }
    </ul>

    <ng-template #defaultItem let-item>{{ item }}</ng-template>
    <ng-template #defaultEmpty>Nothing here</ng-template>
  `,
})
export class DataList<T> {
  items = input.required<readonly T[]>();
  itemTpl = contentChild<ItemTemplate<T>>(ItemTemplate);
  emptyTpl = contentChild(EmptyTemplate);
}
What’s happening
  1. Marker directives. ItemTemplate matches only <ng-template appItem> (the selector ng-template[appItem]). Because it sits on an <ng-template>, Angular injects that template's TemplateRef into its constructor, and the directive simply exposes it as template. EmptyTemplate does the same for the empty state. Marker directives let one component accept several named templates without relying on reference-variable names.
  2. Finding them. DataList uses contentChild(ItemTemplate) and contentChild(EmptyTemplate) — content queries, because the parent writes the templates between <app-data-list> tags. Both are optional signals: the parent may supply neither, one or both.
  3. Rendering. For each item, the outlet renders the parent's template if there is one (itemTpl()?.template), otherwise the component's own #defaultItem. The context { $implicit: item, index: i } supplies the item and its position. @empty does the same for the empty state with #defaultEmpty as fallback.
  4. Typing. static ngTemplateContextGuard is a hook for the template type checker: it tells it that a template carrying this directive has an ItemContext<T> context. T is inferred from the [appItem] input, which the parent binds to the same array it gives the list. So let-p is typed as the item type, not any.
  5. Laziness. Nothing from the parent's templates is created until DataList renders an outlet for it. An empty list never creates an item view; the parent's empty template is never created when there are items. Compare the collapsible in Content projection with ng-content.
AdvancedParent-supplied row templates for a data list

The parent decides what each row looks like:

data-list.ts (demo)
interface Product {
  name: string;
  price: number;
}

@Component({
  selector: 'app-data-list-demo',
  imports: [DataList, ItemTemplate, EmptyTemplate],
  template: `
    <app-data-list [items]="products">
      <ng-template [appItem]="products" let-p let-i="index">
        {{ i + 1 }}. <b>{{ p.name }}</b> — {{ p.price }}
      </ng-template>
    </app-data-list>

    <app-data-list [items]="tags" />

    <app-data-list [items]="none">
      <ng-template appEmpty>No orders yet</ng-template>
    </app-data-list>
  `,
})
export class DataListDemo {
  products: Product[] = [
    { name: 'Keyboard', price: 49 },
    { name: 'Mouse', price: 19 },
  ];
  tags = ['angular', 'rxjs'];
  none: string[] = [];
}
AdvancedRendered lists with default and empty templates
Rendered DOM (one line per list)
<app-data-list><ul><li> 1. <b>Keyboard</b> — 49 </li><li> 2. <b>Mouse</b> — 19 </li></ul></app-data-list>
<app-data-list><ul><li>angular</li><li>rxjs</li></ul></app-data-list>
<app-data-list><ul><li class="empty">No orders yet</li></ul></app-data-list>
What’s happening
  1. List 1 supplies an item template. For Keyboard the context is { $implicit: product, index: 0 }, so p is the product and i is 0 → 1. Keyboard — 49. The template is declared in DataListDemo, so it could also read DataListDemo's fields, but it's stamped by DataList, once per row.
  2. List 2 supplies no templates, so itemTpl() is undefined and every row uses #defaultItem, which prints the item with {{ item }}.
  3. List 3 has no items, so @empty renders, and since the parent supplied <ng-template appEmpty>, its text replaces the default Nothing here.
  4. Type safety, verified. With the context guard in place, a typo in list 1's template ({{ p.nam }}) fails ng build with TS2551: Property 'nam' does not exist on type 'Product'. Did you mean 'name'?. With the guard removed, the same typo compiles without complaint, because p is any.
AdvancedChoosing a projection technique

When to use which technique:

NeedUse
Wrap the parent's markup once, in one place<ng-content />
Several fixed areas (title, body, actions)<ng-content select="…"> slots
Render the parent's markup per item, with dataa template + ngTemplateOutlet with a context
Create the parent's markup only when showna template (content is always created)
Talk to a specific piece the parent providedcontentChild() / contentChildren()
Talk to a piece of your own templateviewChild() / viewChildren()
Where does the content go, and how many times is it built?
HTML
<!-- child template -->
<section><ng-content select="h2" /></section>
@if (expanded()) { <ng-content /> }

<!-- parent template -->
<app-panel>
  <p>Body</p>
  <h2>Title</h2>
  <app-chart />
</app-panel>

expanded() starts as false, and the user expands and collapses the panel three times. Where do <h2>, <p> and <app-chart> end up, and how many times is AppChart's constructor called?

Show answer

<h2> goes in the <section>. <p> and <app-chart> go to the default slot, which is only attached while expanded() is true. AppChart is constructed once, before the panel is first expanded.

What’s happening
  1. Slots are assigned at compile time from the parent's top-level nodes. <h2> matches select="h2" (written second, but the child decides placement). <p> and <app-chart> match nothing, so they belong to the default slot, keeping their source order.
  2. When the parent's template is created, Angular builds all three nodes, including the AppChart instance — constructor called once, even though expanded() is false and the default slot isn't in the DOM.
  3. Each expand attaches the same nodes into the @if block; each collapse detaches them. No new constructor calls and no ngOnDestroy — the chart instance (and any state in it) survives collapsing.
  4. AppChart is destroyed only when the parent's view is destroyed. To build the chart lazily, pass it as <ng-template> and render it with ngTemplateOutlet inside the @if.

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.