Skip to main content

ViewChild and ContentChild in Angular

@ViewChild and @ContentChild are Angular decorators that give you direct access to child elements in a template. Where the element lives determines which one you need.

Theory

TL;DR

  • @ViewChild queries your component's own template; @ContentChild queries content a parent passes in via <ng-content>
  • Both return a single match; use @ViewChildren / @ContentChildren for multiple elements
  • @ViewChild resolves after ngAfterViewInit; @ContentChild after ngAfterContentInit
  • Decision rule: if you wrote the element in your own template, use ViewChild. If a parent passed it in, use ContentChild
  • Angular 17+ has signal-based alternatives: viewChild() and contentChild()

Quick example

typescript
// Parent passes content into the card: // <app-card> // <h2 #title>My Title</h2> <-- projected content // </app-card> @Component({ selector: 'app-card', template: `<ng-content></ng-content>` }) export class CardComponent implements AfterContentInit { @ContentChild('title') titleRef!: ElementRef; ngAfterContentInit() { console.log(this.titleRef.nativeElement.textContent); // "My Title" } }

The h2 lives in the parent's template, not inside CardComponent. That is why @ContentChild works here and @ViewChild would return undefined.

Key difference

@ViewChild looks inside the component's own template file. @ContentChild looks at what a parent projected into the <ng-content> slot. The lifecycle timing follows this split: ViewChild resolves in ngAfterViewInit, ContentChild in ngAfterContentInit. Use the wrong decorator and you get undefined with no helpful error message.

When to use

  • @ViewChild: focus a form input, call a method on a child component, read a DOM element you defined in your own template
  • @ContentChild: building a reusable wrapper like a card, modal, or accordion that needs to interact with what was passed inside
  • @ViewChildren: multiple instances created by *ngFor, batch operations on sibling components
  • @ContentChildren: tab panels, accordion items, any repeated projected content
  • If you only need to pass data down, @Input() is simpler and no query is needed at all

Comparison table

Aspect@ViewChild@ContentChild
QueriesComponent's own templateContent passed via <ng-content>
Available afterngAfterViewInit()ngAfterContentInit()
Multiple elements@ViewChildren@ContentChildren
Who defines the elementThe component itselfThe parent component
Common use caseForm inputs, child component methodsCard headers, tab panels

How Angular resolves queries

Angular scans the template (or projected content) for elements matching the selector. The selector is either a string that matches #ref template variables, or a type that matches the first instance of that component or directive class. For @ViewChild, the scan runs after the component's own DOM renders. For @ContentChild, it runs after the parent inserts projected content into the slot.

The static option changes the timing. { static: true } resolves the query before change detection, making the result available already in ngOnInit. Only use it for elements that always exist in the template and are never wrapped in *ngIf or *ngFor. In practice, static: true is one of the most overlooked details when Angular questions come up in interviews, and the default { static: false } is correct for the overwhelming majority of cases.

Signal queries (Angular 17+)

Angular 17 introduced function-based signal queries as a modern alternative to decorators.

typescript
@Component({ /* ... */ }) export class MyComponent { // No lifecycle hook needed for access nameInput = viewChild.required<ElementRef>('nameInput'); chart = viewChild(ChartComponent); tabs = viewChildren(TabComponent); // Content queries title = contentChild<ElementRef>('title'); buttons = contentChildren(ButtonComponent); ngOnInit() { // Signals are reactive - no ngAfterViewInit required console.log(this.nameInput().nativeElement); } }

Signal queries are reactive by default. The value updates automatically when the template changes, without subscribing to QueryList.changes.

Common mistakes

1. Accessing ViewChild in ngOnInit

The view has not rendered yet at that point. The result is always undefined.

typescript
// Wrong ngOnInit() { console.log(this.input.nativeElement); // TypeError: Cannot read properties of undefined } // Right ngAfterViewInit() { console.log(this.input.nativeElement); // works }

2. Using @ViewChild for projected content

typescript
// Wrong - returns undefined for elements the parent passed in @ViewChild('title') title!: ElementRef; // Right @ContentChild('title') title!: ElementRef;

3. Treating QueryList as a static snapshot

QueryList is live. Cache the length once and it goes stale as soon as items are added or removed conditionally.

typescript
// Wrong - misses dynamically added items const count = this.items.length; // Right - reacts to additions and removals this.items.changes.subscribe(() => { console.log('Current count:', this.items.length); });

4. Missing the read option for directives

When a directive and a component share the same element, Angular defaults to returning the component instance. Use read to get the directive.

typescript
// Wrong - returns the component, not the directive @ViewChild(MyDirective) dir!: MyComponent; // Right @ViewChild(MyDirective, { read: MyDirective }) dir!: MyDirective;

5. Updating template-bound data inside ngAfterViewInit

Setting a property that is bound in the template during ngAfterViewInit triggers ExpressionChangedAfterItHasBeenCheckedError because change detection has already completed.

typescript
// Wrong ngAfterViewInit() { this.title = 'new value'; // Error if title is bound in the template } // Right ngAfterViewInit() { this.title = 'new value'; this.cdr.detectChanges(); // tell Angular to run a second pass }

Real-world usage

  • Angular Material: MatTabGroup uses @ContentChildren(MatTab) to collect tab panels passed by the parent
  • Angular Forms: FormGroupDirective uses @ViewChildren(FormControlName) to track all registered controls
  • PrimeNG: p-dropdown uses @ContentChild(PrimeTemplate) to support custom item templates
  • Custom modals: @ViewChild('closeBtn') to move focus to the close button when the modal opens
  • Data tables: @ViewChildren(TableRowComponent) to update all visible rows after a filter change

Follow-up questions

Q: What is the difference between @ViewChild('ref') and @ViewChild(MyComponent)?
A: String references match template variables (#ref). Type references find the first instance of that class in the template. Type queries are type-safe and easier to refactor when a component is renamed.

Q: Why use @ContentChild instead of @Input()?
A: @Input() passes data. @ContentChild gives you the actual DOM element or component instance, so you can call methods, read DOM properties, or apply styles directly. They solve completely different problems.

Q: Can you use @ViewChild with *ngIf?
A: Yes, but the result is undefined when the condition is false. Always null-check before accessing the element. Use { static: false } (the default) so Angular waits for the element to exist before resolving the query.

Q: What happens if multiple elements share the same template reference variable?
A: @ViewChild returns only the first match. This is a common source of bugs inside loops. Use @ViewChildren to get all matching elements as a QueryList.

Q (senior): How would you build a reusable form validation wrapper that works without the parent knowing which fields are inside?
A: Create a FormGroupComponent that uses @ContentChildren(FormControlName) to discover controls inside <ng-content>. The wrapper handles validation logic and error display internally. The parent just wraps its fields and gets validation for free, with no tight coupling to the wrapper's internals.

Examples

Basic: controlling a native input with @ViewChild

typescript
@Component({ selector: 'app-search', template: ` <input #searchInput type="text" placeholder="Search..."> <button (click)="focusSearch()">Focus</button> ` }) export class SearchComponent implements AfterViewInit { @ViewChild('searchInput') searchInput!: ElementRef<HTMLInputElement>; ngAfterViewInit() { // DOM is ready here this.searchInput.nativeElement.focus(); } focusSearch() { this.searchInput.nativeElement.focus(); } }

@ViewChild resolves #searchInput after the component's view renders. Calling .focus() in ngOnInit would throw because the element does not exist yet at that stage.

Intermediate: @ContentChild in a form wrapper

typescript
// Reusable wrapper - does not know which fields live inside @Component({ selector: 'app-form-group', template: `<div class="form-group"><ng-content></ng-content></div>` }) export class FormGroupComponent implements AfterContentInit { @ContentChild('password') passwordField!: ElementRef; ngAfterContentInit() { // The input was defined in the parent and projected here console.log('Field ready:', this.passwordField.nativeElement); } } // Parent projects the input into the wrapper @Component({ selector: 'app-login', template: ` <app-form-group> <input #password type="password" placeholder="Password"> </app-form-group> ` }) export class LoginComponent {}

The #password input lives in LoginComponent's template. FormGroupComponent accesses it via @ContentChild because it was projected in, not defined inside the wrapper itself.

Advanced: live tab system with @ContentChildren

typescript
@Component({ selector: 'app-tabs', template: ` <div class="tab-bar"> <button *ngFor="let tab of tabs; let i = index" (click)="selectTab(i)"> {{ tab.label }} </button> </div> <ng-content></ng-content> ` }) export class TabsComponent implements AfterContentInit { @ContentChildren('tabPanel') tabPanels!: QueryList<ElementRef>; tabs: { label: string }[] = []; ngAfterContentInit() { this.buildTabs(); // QueryList is live - updates when panels are added or removed this.tabPanels.changes.subscribe(() => this.buildTabs()); } buildTabs() { this.tabs = this.tabPanels.map((_, i) => ({ label: `Tab ${i + 1}` })); } selectTab(index: number) { this.tabPanels.forEach((panel, i) => { panel.nativeElement.hidden = i !== index; }); } }

Usage:

html
<app-tabs> <div #tabPanel>Content 1</div> <div #tabPanel>Content 2</div> <div #tabPanel>Content 3</div> </app-tabs>

QueryList.changes keeps the tab bar in sync when panels are added or removed at runtime. Without that subscription, the tab count would freeze at whatever it was during initialization.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?