TL;DR
- Angular structural directives: Control DOM rendering with powerful template manipulation
- @if vs *ngIf: Use
@if
for cleaner, block-based syntax in Angular 17+; use*ngIf
for backward compatibility - Custom structural directives: Create reusable template logic with
TemplateRef
andViewContainerRef
- Performance optimization: Prevent duplicate view creation with proper state tracking
- Practical applications: Implement permission-based rendering, feature flags, and conditional animations
- Testing strategies: Verify directive behavior with TestBed and component fixtures
- Best practices: Keep business logic in directives for better encapsulation and cleaner component templates
Structural directives are Angular’s way of manipulating the DOM structure, adding, removing, or modifying elements based on conditions.
If you’ve ever wondered how *ngIf
actually works under the hood, or why Angular introduced the new @if
syntax, building your own structural directive is the fastest way to understand it.
Think of structural directives as instructions to add or remove chunks of the DOM like Lego blocks. Angular takes your template, compiles it into these movable pieces, then decides when to attach or detach them based on your logic.
The *ngIf
vs @if
Syntax Split
Before we build our own, let’s clarify the two approaches in Angular 17+:
// Old structural directive syntax
<div *ngIf="showContent">Content here</div>
// New control flow syntax
@if (showContent) {
<div>Content here</div>
}
The @if
syntax is cleaner for complex conditions, but custom structural directives still use the *
syntax. Here’s when to use each:
- Use
@if
: For simple conditional rendering in templates - Use
*ngIf
or custom directives: When you need reusable logic, custom behaviors, or complex DOM manipulation
Building a Custom *ngShow
Directive
Let’s build *ngShow
that works like *ngIf
but with a fade animation. This will show you exactly how structural directives work:
import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
@Directive({
selector: '[ngShow]',
standalone: true
})
export class NgShowDirective {
constructor(
private templateRef: TemplateRef<any>, // The template to show/hide
private viewContainer: ViewContainerRef // Where to insert the template
) {}
@Input() set ngShow(condition: boolean) {
if (condition) {
// Create the view from template and insert it
this.viewContainer.createEmbeddedView(this.templateRef);
} else {
// Remove all views from the container
this.viewContainer.clear();
}
}
}
Here’s the tricky part: Angular injects TemplateRef
and ViewContainerRef
automatically. The TemplateRef
is your template content, and ViewContainerRef
is the insertion point where Angular will render it.
Using Your Custom Directive
@Component({
selector: 'app-demo',
standalone: true,
imports: [NgShowDirective, CommonModule],
template: `
<button (click)="toggle()">Toggle Content</button>
<!-- Your custom structural directive -->
<div *ngShow="isVisible">
<h3>This content fades in/out</h3>
<p>Built with a custom structural directive!</p>
</div>
<!-- Compare with built-in @if -->
@if (isVisible) {
<div>
<h3>This content appears instantly</h3>
<p>Built with @if control flow</p>
</div>
}
`
})
export class DemoComponent {
isVisible = false;
toggle() {
this.isVisible = !this.isVisible;
}
}
How Angular Compiles the *
Syntax
When Angular sees *ngShow="condition"
, it transforms it into this:
// What you write:
<div *ngShow="isVisible">Content</div>
// What Angular compiles:
<ng-template [ngShow]="isVisible">
<div>Content</div>
</ng-template>
The *
is syntactic sugar. Angular wraps your content in an <ng-template>
and passes it to your directive as TemplateRef
. This is why structural directives receive templates, not elements.
Advanced: Context and Data Passing
You can pass data to your template context, similar to how *ngFor
provides index
and item
:
@Directive({
selector: '[ngShowWithDelay]',
standalone: true
})
export class NgShowWithDelayDirective {
constructor(
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef
) {}
@Input() set ngShowWithDelay(condition: boolean) {
if (condition) {
// Pass context data to the template
const context = {
$implicit: 'Delayed content!',
timestamp: new Date()
};
this.viewContainer.createEmbeddedView(this.templateRef, context);
} else {
this.viewContainer.clear();
}
}
}
Use it like this:
<div *ngShowWithDelay="showDelayed; let message; let time = timestamp">
{{ message }} - Rendered at {{ time }}
</div>
When to Build Custom Structural Directives
Real use cases for custom structural directives:
- Permission-based rendering:
*ngIfRole="'admin'"
- Feature flags:
*ngIfFeature="'new-dashboard'"
- Loading states:
*ngShowWhenLoaded="data$"
- Conditional animations:
*ngSlideIf="condition"
The Mental Model
Structural directives are DOM manipulation instructions. Angular gives you:
- TemplateRef: The blueprint (your HTML template)
- ViewContainerRef: The construction site (where to build)
- Input conditions: When to build or demolish
You decide when to call createEmbeddedView()
(build) or clear()
(demolish) based on your logic.
Performance Note
When considering performance implications:
- @if is compiled faster by Angular’s compiler because it’s a built-in control flow feature
- Custom directives take slightly longer to compile but provide reusability and encapsulation
- For large-scale apps with many instances, the compilation difference is negligible compared to runtime performance
- Both approaches have identical runtime performance once compiled to JavaScript
The real advantage of custom structural directives isn’t performance but reusability across components.
Error Handling & Common Gotchas
Watch out for these common pitfalls when building custom structural directives:
// INCORRECT - Creates duplicate views
@Input() set ngShow(condition: boolean) {
if (condition) {
this.viewContainer.createEmbeddedView(this.templateRef); // Called on EVERY change!
} else {
this.viewContainer.clear();
}
}
// CORRECT - Prevents duplicate views
private hasView = false;
@Input() set ngShow(condition: boolean) {
if (condition && !this.hasView) {
this.viewContainer.createEmbeddedView(this.templateRef);
this.hasView = true;
} else if (!condition && this.hasView) {
this.viewContainer.clear();
this.hasView = false;
}
}
Other issues to watch for:
- Always clean up subscriptions in
ngOnDestroy()
- Be careful with context objects, they aren’t automatically updated
- Avoid CPU-intensive operations in structural directive logic
- Remember that embedded views cause additional change detection cycles
Testing Custom Structural Directives
Testing structural directives requires a host component to provide the context:
// Testing a custom structural directive
describe('NgShowDirective', () => {
let fixture: ComponentFixture<TestComponent>;
@Component({
template: `<div *ngShow="visible">Test content</div>`,
standalone: true,
imports: [NgShowDirective]
})
class TestComponent {
visible = false;
}
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TestComponent]
});
fixture = TestBed.createComponent(TestComponent);
});
it('should show content when visible is true', () => {
const component = fixture.componentInstance;
// Initially hidden
fixture.detectChanges();
expect(fixture.debugElement.queryAll(By.css('div')).length).toBe(0);
// Show content
component.visible = true;
fixture.detectChanges();
expect(fixture.debugElement.queryAll(By.css('div')).length).toBe(1);
expect(fixture.debugElement.queryAll(By.directive(NgShowDirective)).length).toBe(1);
});
});
This approach lets you verify that your directive correctly adds and removes content from the DOM based on its input conditions.
@if
vs Custom Directives: The Bottom Line
Use @if
for simple conditional rendering, it’s faster and cleaner. Build custom structural directives when you need reusable logic that other components can import and use.
The new @if
syntax improves template readability, but understanding how *ngIf
and custom structural directives work gives you the power to build reusable DOM manipulation logic that feels native to Angular.
Related Posts
- Stop Subscribing in Angular Components: Use Async Pipe + Guard Clauses Instead
- Lazy Loading in Angular 19: Modules vs Standalone Components
- Angular Interceptor with Retry, Timeout & Unified Error Handling
- Angular Signals: A Hands-on Guide to Better State Management
- RxJS map vs switchMap vs concatMap vs exhaustMap in Angular: Real-World Patterns