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
@ViewChildqueries your component's own template;@ContentChildqueries content a parent passes in via<ng-content>- Both return a single match; use
@ViewChildren/@ContentChildrenfor multiple elements @ViewChildresolves afterngAfterViewInit;@ContentChildafterngAfterContentInit- 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()andcontentChild()
Quick example
// 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 |
|---|---|---|
| Queries | Component's own template | Content passed via <ng-content> |
| Available after | ngAfterViewInit() | ngAfterContentInit() |
| Multiple elements | @ViewChildren | @ContentChildren |
| Who defines the element | The component itself | The parent component |
| Common use case | Form inputs, child component methods | Card 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.
@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.
// 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
// 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.
// 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.
// 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.
// 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:
MatTabGroupuses@ContentChildren(MatTab)to collect tab panels passed by the parent - Angular Forms:
FormGroupDirectiveuses@ViewChildren(FormControlName)to track all registered controls - PrimeNG:
p-dropdownuses@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
@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
// 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
@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:
<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 readyA concise answer to help you respond confidently on this topic during an interview.