How would you let consumers of an Angular data-table component supply their own cell template, and whose data and providers does that template see?
answer
- pass a template, not markup
- TemplateRef input or content query
- row goes in $implicit
- declaration site wins by default
basics
~10 sAccept a TemplateRef (input or content query) and render it per row with NgTemplateOutlet, passing the row as $implicit. The template still evaluates against, and by default injects from, the consumer that declared it.
solid answer
~40 sI'd define a context type such as `{ $implicit: T; column: string; index: number }`, accept `input<TemplateRef<CellContext<T>>>()` or pick the template up with a content query, and in the table's `@for` render `<ng-container [ngTemplateOutlet]="tpl" [ngTemplateOutletContext]="{ $implicit: row, ... }" />`, with an `@else` default. The consumer writes `<ng-template #status let-order>...</ng-template>`. The key point is scope: bindings inside that template run against the **consumer**, so `(click)="cancel(order)"` calls the consumer's method, and the table contributes only the context. DI also resolves from the declaration site by default; since v21.2 `ngTemplateOutletInjector: 'outlet'` switches to the outlet's injector. When the consumer's view refreshes, the stamped cells refresh too, even inside an `OnPush` table.
code
ts · 32 linesimport { Component, TemplateRef, input } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
export interface CellContext<T> {
$implicit: T;
index: number;
}
@Component({
selector: 'app-data-table',
imports: [NgTemplateOutlet],
template: `
<table>
@for (row of rows(); track row.id; let i = $index) {
<tr>
<td>
@if (cellTemplate(); as tpl) {
<ng-container [ngTemplateOutlet]="tpl"
[ngTemplateOutletContext]="{ $implicit: row, index: i }" />
} @else {
{{ row.label }}
}
</td>
</tr>
}
</table>
`,
})
export class DataTable<T extends { id: string; label: string }> {
rows = input.required<T[]>();
cellTemplate = input<TemplateRef<CellContext<T>>>();
}go deeper
Recall that a template can be passed around as a TemplateRef and rendered with NgTemplateOutlet plus a context object.
Walk through the contract: a TemplateRef input or content query, the per-row outlet, the row under $implicit and a default rendering.
Explain declaration-site scope for bindings, DI and change detection, and type the context so consumers get compile-time errors.
Treat the template contract as a public API: keep the context minimal, name slots per column and plan how it evolves across releases.
## The shape of the problem A reusable `data-table` component owns the rows, the sorting and the layout, but each consumer wants to decide how a particular cell looks: a status badge, a link, a formatted amount with an action button. Content projection with `<ng-content>` cannot do this on its own, because projected content is rendered once by the parent and cannot receive the current row. The idiomatic Angular answer is to let the consumer hand the table a **template**, and let the table **stamp it once per row** with that row as context. ## Step 1: define the contract as a context type ```ts export interface CellContext<T> { $implicit: T; // the row column: string; index: number; } ``` Putting the row under **`$implicit`** means the consumer can write `let-row` without naming a key. ## Step 2: accept a `TemplateRef` in the table There are two common ways to receive it: - a signal **input**: `cellTemplate = input<TemplateRef<CellContext<T>>>()`; the consumer passes `[cellTemplate]="statusCell"`; - a **content query** for a template the consumer places *inside* the table tag, usually marked with a small directive so several named templates can be told apart. Either way the table ends up holding a `TemplateRef`, not rendered DOM, so nothing in the consumer's template runs until the table decides to render it. ## Step 3: render it per row with `NgTemplateOutlet` ```ts @Component({ selector: 'app-data-table', imports: [NgTemplateOutlet], template: ` @for (row of rows(); track row.id; let i = $index) { <tr> <td> @if (cellTemplate(); as tpl) { <ng-container [ngTemplateOutlet]="tpl" [ngTemplateOutletContext]="{ $implicit: row, column: 'status', index: i }" /> } @else { {{ row.status }} } </td> </tr> } `, }) export class DataTable<T extends { id: string; status: string }> { rows = input.required<T[]>(); cellTemplate = input<TemplateRef<CellContext<T>>>(); } ``` The `@else` branch gives a **default rendering** when no template is supplied, which is how most table libraries behave. ## Step 4: what the consumer writes ```html <app-data-table [rows]="orders()" [cellTemplate]="statusCell" /> <ng-template #statusCell let-order let-i="index"> <span [class]="order.status">{{ order.status }}</span> <button (click)="cancel(order)">Cancel</button> </ng-template> ``` ## Whose data, whose providers This is the part interviewers probe, because it surprises people: 1. **Expressions resolve against the declaring component.** `cancel(order)` calls the *consumer's* `cancel` method, even though the button renders inside the table. The table's own properties are **not** visible to the fragment; only what it passes through the context is. 2. **Dependency injection resolves from the declaration site by default.** A component inside the fragment injects from the consumer's injector tree, not from the table's providers. Since v21.2, `NgTemplateOutlet` accepts `ngTemplateOutletInjector` set to the string `'outlet'` to make the embedded view inherit the injector at the outlet's location instead, or an explicit `Injector`. 3. **Change detection follows the declaration too.** When the consumer's view is refreshed, Angular marks the views stamped from its templates for refresh at their insertion points, so the cell updates even if the table itself is `OnPush` and received no new input. | Question | Answer for a stamped template | |---|---| | Which `this` do bindings use? | The component that declared `<ng-template>` | | What does the table contribute? | Only the context object | | Which injector by default? | The declaration site's | | Where does the DOM go? | Next to the outlet, inside the table | ## Why not plain content projection `<ng-content>` projects nodes the parent has **already rendered once**. It cannot be repeated per row, and the table cannot hand those nodes a row. A template is the opposite: a recipe the table can run as many times as it has rows, each time with a different context. That is why reusable table, list and tab components commonly accept templates rather than projected markup for per-item rendering. ## Design choices a senior should raise - **Type the context.** A `TemplateRef<CellContext<T>>` input lets strict template checking reject a wrongly shaped context object in the table's own template. - **Keep the context small and stable.** Pass the row and the few values the table owns; do not pass the whole table instance, which couples consumers to its internals. - **Offer defaults.** A table that renders nothing without a template is hard to adopt. - **Name the slots.** For several columns, a map of column name to `TemplateRef`, or a marker directive per column queried with a content query, scales better than one input per column.
- A component inside the cell template injects a service the table provides, but gets the consumer's instance instead. Why, and what are the fixes?An embedded view's injector comes from where the template is declared, so it resolves through the consumer's tree. Pass the table's providers explicitly, or since v21.2 set `ngTemplateOutletInjector` to `'outlet'` so the view inherits the injector at the outlet inside the table.
- The table is OnPush and its inputs do not change; does a cell template showing the consumer's changing state update?Yes. When the declaring view is refreshed, Angular marks the views stamped from its templates for refresh at their insertion points, so the cells update even though the OnPush table itself was not marked dirty.
- When would you choose a content query over a TemplateRef input?When consumers should declare templates inside the table tag, often several at once marked with a small directive per column. An input suits one template passed by reference; a content query reads more naturally for per-column or named templates.
saying these in an interview costs you the question
- Uses <ng-content> alone and expects it to receive each row
- Thinks the cell template can read the table's own fields directly
- Assumes a cell template injects from the table's providers by default
- Passes the whole table instance in the context for convenience
- Believes an OnPush table blocks updates to the consumer's cell template