Why does an Angular tabs component using contentChildren(Tab) show no tabs when a consumer wraps the <app-tab> elements in a <div>, and how do you fix it?
answer
- direct children by default
- ng-container and control flow are transparent
- descendants: true within one template
- still stops at component boundaries
basics
~20 scontentChildren() and @ContentChildren match only direct children of the host by default, and a wrapping <div> makes the tabs grandchildren. Set descendants: true, which still stops at other components' templates, or keep tabs as direct children.
solid answer
~40 sContent queries have a `descendants` option. For `contentChildren()` and `@ContentChildren` it defaults to `false`, so only nodes that are **direct children** of the component's tags in the consumer's template match. `<ng-container>` wrappers and control-flow blocks such as `@for` placed directly inside are transparent, but a real element like `<div>` is not, so the wrapped tabs are skipped and the list is empty. Fix it with `contentChildren(Tab, {descendants: true})`, which searches all nesting levels **within the consumer's template**. It still never finds a `Tab` rendered inside another component's template, so a consumer who builds tabs inside their own component needs a different design, such as the tab registering itself with the parent through DI. Note that `contentChild()` defaults to `descendants: true`, which is why a single-result query often works in the same markup.
code
html · 14 lines<!-- contentChildren(Tab) with the default descendants: false -->
<app-tabs>
<app-tab label="A" /> <!-- matched: direct child -->
<ng-container>
<app-tab label="B" /> <!-- matched: ng-container is transparent -->
</ng-container>
@for (t of extra; track t) {
<app-tab [label]="t" /> <!-- matched: block root node -->
}
<div class="row">
<app-tab label="C" /> <!-- NOT matched without descendants: true -->
</div>
<app-more-tabs /> <!-- tabs in its own template: never matched -->
</app-tabs>go deeper
Remember that a plural content query only sees direct children unless you pass descendants: true.
Explain which wrappers are transparent, why contentChild and contentChildren have different defaults, and that no option crosses a component boundary.
Diagnose empty content queries quickly, weigh descendants: true against nested tab sets, and switch to DI registration when tabs come from other components.
Decide how strict a library's markup contract should be: documented direct children, descendant queries, or registration that tolerates any composition.
## The component A tabs widget lets consumers declare tabs as content and renders a header for each one: ```ts import {Component, contentChildren, input, signal} from '@angular/core'; @Component({selector: 'app-tab', template: `<ng-content />`}) export class Tab { label = input.required<string>(); } @Component({ selector: 'app-tabs', template: ` <nav> @for (tab of tabs(); track tab; let i = $index) { <button (click)="selected.set(i)">{{ tab.label() }}</button> } </nav> <ng-content /> `, }) export class Tabs { tabs = contentChildren(Tab); selected = signal(0); } ``` With `<app-tabs><app-tab label="A">..</app-tab><app-tab label="B">..</app-tab></app-tabs>` it works. A consumer then writes: ```html <app-tabs> <div class="tab-grid"> <app-tab label="A">..</app-tab> <app-tab label="B">..</app-tab> </div> </app-tabs> ``` No headers appear. `tabs()` is an empty array. ## Why: descendants defaults differ Content queries accept a **`descendants`** option that controls how deep they look inside the component's tags in the consumer's template. | Query | Default `descendants` | |---|---| | `contentChildren()` / `@ContentChildren` | `false`: direct children only | | `contentChild()` / `@ContentChild` | `true`: any depth | | view queries | no option; always any depth within the view | With `false`, a node matches only if its parent in the consumer's template is the component's host element. Two kinds of wrapper do not count as a level: - **`<ng-container>`**: grouping without an element is looked through. - **Templates and control flow**: a `@for` or `@if` block placed directly inside the host is itself treated as a direct child, and its root nodes match. A real element such as `<div class="tab-grid">` does count, so the tabs become grandchildren and are excluded. ## Fix 1: opt into descendants ```ts tabs = contentChildren(Tab, {descendants: true}); ``` Now every `Tab` nested at any depth inside `<app-tabs>`, within the consumer's template, is found. This is the right fix when layout wrappers are a legitimate need. The tradeoff: with `descendants: true`, a **nested** `<app-tabs>` inside one of the tabs would have its own tabs picked up by the outer query too, since they are also descendants in the same template. Tab sets that nest need to filter, for example by keeping only tabs whose injected parent `Tabs` is this instance. ## What descendants cannot do `descendants: true` never crosses a component boundary. If a consumer writes `<app-tabs><app-settings-tabs /></app-tabs>` and `SettingsTabs` renders `<app-tab>` elements in *its own* template, those tabs are invisible to `Tabs`, because they belong to a different template. Two designs handle that: 1. **Registration through DI.** `Tab` injects the nearest `Tabs` with `inject(Tabs)` and registers itself on creation, unregistering when destroyed. This works across component boundaries because DI walks the element injector tree, while queries do not. 2. **Templates as input.** The consumer passes tab templates explicitly, and `Tabs` renders them. ## Checklist when a content query comes back empty 1. Is the target a direct child, or wrapped in a real element? Consider `descendants: true`. 2. Is the target declared in the consumer's template, or rendered inside another component? Queries cannot see the latter. 3. Is the query a view query by mistake? Projected nodes are content, never view. 4. Is the node conditional? An `@if` that is false yields no match until it becomes true, and signal queries then update on their own. 5. For decorator queries, are you reading before `ngAfterContentInit`? ## Documenting the contract Whichever fix you choose, the markup a consumer may write is part of the component's public API. A tabs component that keeps `descendants: false` should say that tabs must be direct children, optionally grouped with `<ng-container>` or generated with `@for`. One that sets `descendants: true` should say whether nested tab sets are supported. A component test that projects each supported shape (plain, wrapped, generated, nested) and asserts the header count protects that contract from a later refactor that changes the query options without anyone noticing. ## Dynamic tabs keep working With signal queries, adding or removing tabs through `@for` in the consumer's template updates `tabs()` automatically, and the header `@for` re-renders. The result array keeps the same reference when a check changes nothing, so a `computed()` over `tabs()` does not rerun needlessly. With `@ContentChildren` you would subscribe to `QueryList.changes` for the same effect.
- Why does contentChild(Tab) find the first wrapped tab when contentChildren(Tab) finds none?The defaults differ: single-result content queries default to `descendants: true`, plural ones to `false`. The same markup therefore matches at depth for `contentChild` but only at the top level for `contentChildren`, unless you set the option explicitly.
- How would a tab rendered inside another component's template join the tab set?Not through a query. The `Tab` can `inject(Tabs)` from the element injector tree and register itself, removing itself on destroy. DI resolution climbs through component boundaries, while queries never do.
The default contentChildren is a teacher counting only the pupils sitting directly at the front desk; descendants: true counts everyone in the classroom. Neither counts pupils in the classroom next door, which is another component's template.
saying these in an interview costs you the question
- contentChildren searches all nesting levels by default
- descendants: true lets a query see inside other components' templates
- Wrapping tabs in ng-container breaks a default contentChildren query
- The fix is to switch to viewChildren because the tabs render inside the component
- A @for block around the tabs always hides them from the query