Files

7.8 KiB

Angular Component Patterns

Table of Contents

Model Inputs (Two-Way Binding)

For two-way binding with [(value)] syntax:

import { Component, model } from '@angular/core';

@Component({
    selector: 'app-slider',
    host: {
        '(input)': 'onInput($event)',
    },
    template: `
        <input
            type="range"
            [value]="value()"
            [min]="min()"
            [max]="max()" />
        <span>{{ value() }}</span>
    `,
})
export class Slider {
    // Model creates both input and output
    value = model(0);
    min = input(0);
    max = input(100);

    onInput(event: Event) {
        const target = event.target as HTMLInputElement;
        this.value.set(Number(target.value));
    }
}

// Usage: <app-slider [(value)]="sliderValue" />

Required model:

value = model.required<number>();

View Queries

Query elements and components in the template:

import { Component, viewChild, viewChildren, ElementRef } from '@angular/core';

@Component({
    selector: 'app-gallery',
    template: `
        <div
            #container
            class="gallery">
            @for (image of images(); track image.id) {
                <app-image-card [image]="image" />
            }
        </div>
    `,
})
export class Gallery {
    images = input.required<Image[]>();

    // Query single element
    container = viewChild.required<ElementRef<HTMLDivElement>>('container');

    // Query single component (optional)
    firstCard = viewChild(ImageCard);

    // Query all matching components
    allCards = viewChildren(ImageCard);
}

Content Queries

Query projected content:

import { Component, contentChild, contentChildren, effect, signal } from '@angular/core';

@Component({
    selector: 'app-tabs',
    template: `
        <div class="tab-headers">
            @for (tab of tabs(); track tab.label()) {
                <button
                    [class.active]="tab === activeTab()"
                    (click)="selectTab(tab)">
                    {{ tab.label() }}
                </button>
            }
        </div>
        <div class="tab-content">
            <ng-content />
        </div>
    `,
})
export class Tabs {
    // Query all projected Tab children
    tabs = contentChildren(Tab);

    // Query single projected element
    header = contentChild('tabHeader');

    activeTab = signal<Tab | undefined>(undefined);

    constructor() {
        // Set first tab as active when tabs are available
        effect(() => {
            const firstTab = this.tabs()[0];
            if (firstTab && !this.activeTab()) {
                this.activeTab.set(firstTab);
            }
        });
    }

    selectTab(tab: Tab) {
        this.activeTab.set(tab);
    }
}

@Component({
    selector: 'app-tab',
    template: `<ng-content />`,
    host: {
        '[class.active]': 'isActive()',
        '[style.display]': 'isActive() ? "block" : "none"',
    },
})
export class Tab {
    label = input.required<string>();
    isActive = input(false);
}

Dependency Injection in Components

Use inject() function instead of constructor injection:

import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';

@Component({
    selector: 'app-dashboard',
    template: `...`,
})
export class Dashboard {
    private router = inject(Router);
    private userService = inject(User);
    private config = inject(APP_CONFIG);

    // Optional injection
    private analytics = inject(Analytics, { optional: true });

    // Self-only injection
    private localService = inject(Local, { self: true });

    navigateToProfile() {
        this.router.navigate(['/profile']);
    }
}

Component Communication Patterns

Parent to Child (Inputs)

// Parent
@Component({
    template: `<app-child
        [data]="parentData()"
        [config]="config" />`,
})
export class Parent {
    parentData = signal({ name: 'Test' });
    config = { theme: 'dark' };
}

// Child
@Component({ selector: 'app-child' })
export class Child {
    data = input.required<Data>();
    config = input<Config>();
}

Child to Parent (Outputs)

// Child
@Component({
    selector: 'app-child',
    template: `<button (click)="save()">Save</button>`,
})
export class Child {
    saved = output<Data>();

    save() {
        this.saved.emit({ id: 1, name: 'Item' });
    }
}

// Parent
@Component({
    template: `<app-child (saved)="onSaved($event)" />`,
})
export class Parent {
    onSaved(data: Data) {
        console.log('Saved:', data);
    }
}

Shared Service Pattern

// Shared state service
@Injectable({ providedIn: 'root' })
export class Cart {
    private items = signal<CartItem[]>([]);

    readonly items$ = this.items.asReadonly();
    readonly total = computed(() => this.items().reduce((sum, item) => sum + item.price, 0));

    addItem(item: CartItem) {
        this.items.update((items) => [...items, item]);
    }

    removeItem(id: string) {
        this.items.update((items) => items.filter((i) => i.id !== id));
    }
}

// Component A
@Component({ template: `<button (click)="add()">Add</button>` })
export class Product {
    private cart = inject(Cart);
    product = input.required<Product>();

    add() {
        this.cart.addItem({ ...this.product(), quantity: 1 });
    }
}

// Component B
@Component({ template: `<span>Total: {{ cart.total() }}</span>` })
export class CartSummary {
    cart = inject(Cart);
}

Dynamic Components

Using @defer for lazy loading:

@Component({
    template: `
        @defer (on viewport) {
            <app-heavy-chart [data]="chartData()" />
        } @placeholder {
            <div class="chart-placeholder">Loading chart...</div>
        } @loading (minimum 500ms) {
            <app-spinner />
        } @error {
            <p>Failed to load chart</p>
        }
    `,
})
export class Dashboard {
    chartData = input.required<ChartData>();
}

Defer triggers:

  • on viewport - When element enters viewport
  • on idle - When browser is idle
  • on interaction - On user interaction (click, focus)
  • on hover - On mouse hover
  • on immediate - Immediately after non-deferred content
  • on timer(500ms) - After specified delay
  • when condition - When expression becomes true
@Component({
    template: `
        @defer (on interaction; prefetch on idle) {
            <app-comments [postId]="postId()" />
        } @placeholder {
            <button>Load Comments</button>
        }
    `,
})
export class Post {
    postId = input.required<string>();
}

Attribute Directives on Components

@Directive({
    selector: '[appHighlight]',
    host: {
        '[style.backgroundColor]': 'color()',
    },
})
export class Highlight {
    color = input('yellow', { alias: 'appHighlight' });
}

// Usage on component
@Component({
    imports: [Highlight],
    template: `<app-card appHighlight="lightblue" />`,
})
export class Page {}

Error Boundaries

@Component({
    selector: 'app-error-boundary',
    template: `
        @if (hasError()) {
            <div class="error">
                <h3>Something went wrong</h3>
                <button (click)="retry()">Retry</button>
            </div>
        } @else {
            <ng-content />
        }
    `,
})
export class ErrorBoundary {
    hasError = signal(false);
    private errorHandler = inject(ErrorHandler);

    retry() {
        this.hasError.set(false);
    }
}