feat: add Angular NgRx best practices documentation

This commit is contained in:
Dennis Hundertmark
2026-03-08 08:51:02 +01:00
parent 67dc823270
commit 2184971175
47 changed files with 8490 additions and 0 deletions
+302
View File
@@ -0,0 +1,302 @@
---
name: angular-component
description: Create modern Angular standalone components following v20+ best practices. Use for building UI components with signal-based inputs/outputs, OnPush change detection, host bindings, content projection, and lifecycle hooks. Triggers on component creation, refactoring class-based inputs to signals, adding host bindings, or implementing accessible interactive components.
---
# Angular Component
Create standalone components for Angular v20+. Components are standalone by default—do NOT set `standalone: true`.
## Component Structure
```typescript
import {
Component,
ChangeDetectionStrategy,
input,
output,
computed,
} from "@angular/core";
@Component({
selector: "app-user-card",
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: "user-card",
"[class.active]": "isActive()",
"(click)": "handleClick()",
},
template: `
<img [src]="avatarUrl()" [alt]="name() + ' avatar'" />
<h2>{{ name() }}</h2>
@if (showEmail()) {
<p>{{ email() }}</p>
}
`,
styles: `
:host {
display: block;
}
:host.active {
border: 2px solid blue;
}
`,
})
export class UserCard {
// Required input
name = input.required<string>();
// Optional input with default
email = input<string>("");
showEmail = input(false);
// Input with transform
isActive = input(false, { transform: booleanAttribute });
// Computed from inputs
avatarUrl = computed(() => `https://api.example.com/avatar/${this.name()}`);
// Output
selected = output<string>();
handleClick() {
this.selected.emit(this.name());
}
}
```
## Signal Inputs
```typescript
// Required - must be provided by parent
name = input.required<string>();
// Optional with default value
count = input(0);
// Optional without default (undefined allowed)
label = input<string>();
// With alias for template binding
size = input("medium", { alias: "buttonSize" });
// With transform function
disabled = input(false, { transform: booleanAttribute });
value = input(0, { transform: numberAttribute });
```
## Signal Outputs
```typescript
import { output, outputFromObservable } from "@angular/core";
// Basic output
clicked = output<void>();
selected = output<Item>();
// With alias
valueChange = output<number>({ alias: "change" });
// From Observable (for RxJS interop)
scroll$ = new Subject<number>();
scrolled = outputFromObservable(this.scroll$);
// Emit values
this.clicked.emit();
this.selected.emit(item);
```
## Host Bindings
Use the `host` object in `@Component`—do NOT use `@HostBinding` or `@HostListener` decorators.
```typescript
@Component({
selector: "app-button",
host: {
// Static attributes
role: "button",
// Dynamic class bindings
"[class.primary]": 'variant() === "primary"',
"[class.disabled]": "disabled()",
// Dynamic style bindings
"[style.--btn-color]": "color()",
// Attribute bindings
"[attr.aria-disabled]": "disabled()",
"[attr.tabindex]": "disabled() ? -1 : 0",
// Event listeners
"(click)": "onClick($event)",
"(keydown.enter)": "onClick($event)",
"(keydown.space)": "onClick($event)",
},
template: `<ng-content />`,
})
export class Button {
variant = input<"primary" | "secondary">("primary");
disabled = input(false, { transform: booleanAttribute });
color = input("#007bff");
clicked = output<void>();
onClick(event: Event) {
if (!this.disabled()) {
this.clicked.emit();
}
}
}
```
## Content Projection
```typescript
@Component({
selector: "app-card",
template: `
<header>
<ng-content select="[card-header]" />
</header>
<main>
<ng-content />
</main>
<footer>
<ng-content select="[card-footer]" />
</footer>
`,
})
export class Card {}
// Usage:
// <app-card>
// <h2 card-header>Title</h2>
// <p>Main content</p>
// <button card-footer>Action</button>
// </app-card>
```
## Lifecycle Hooks
```typescript
import { OnDestroy, OnInit, afterNextRender, afterRender } from "@angular/core";
export class My implements OnInit, OnDestroy {
constructor() {
// For DOM manipulation after render (SSR-safe)
afterNextRender(() => {
// Runs once after first render
});
afterRender(() => {
// Runs after every render
});
}
ngOnInit() {
/* Component initialized */
}
ngOnDestroy() {
/* Cleanup */
}
}
```
## Accessibility Requirements
Components MUST:
- Pass AXE accessibility checks
- Meet WCAG AA standards
- Include proper ARIA attributes for interactive elements
- Support keyboard navigation
- Maintain visible focus indicators
```typescript
@Component({
selector: "app-toggle",
host: {
role: "switch",
"[attr.aria-checked]": "checked()",
"[attr.aria-label]": "label()",
tabindex: "0",
"(click)": "toggle()",
"(keydown.enter)": "toggle()",
"(keydown.space)": "toggle(); $event.preventDefault()",
},
template: `<span class="toggle-track"
><span class="toggle-thumb"></span
></span>`,
})
export class Toggle {
label = input.required<string>();
checked = input(false, { transform: booleanAttribute });
checkedChange = output<boolean>();
toggle() {
this.checkedChange.emit(!this.checked());
}
}
```
## Template Syntax
Use native control flow—do NOT use `*ngIf`, `*ngFor`, `*ngSwitch`.
```html
<!-- Conditionals -->
@if (isLoading()) {
<app-spinner />
} @else if (error()) {
<app-error [message]="error()" />
} @else {
<app-content [data]="data()" />
}
<!-- Loops -->
@for (item of items(); track item.id) {
<app-item [item]="item" />
} @empty {
<p>No items found</p>
}
<!-- Switch -->
@switch (status()) { @case ('pending') { <span>Pending</span> } @case ('active')
{ <span>Active</span> } @default { <span>Unknown</span> } }
```
## Class and Style Bindings
Do NOT use `ngClass` or `ngStyle`. Use direct bindings:
```html
<!-- Class bindings -->
<div [class.active]="isActive()">Single class</div>
<div [class]="classString()">Class string</div>
<!-- Style bindings -->
<div [style.color]="textColor()">Styled text</div>
<div [style.width.px]="width()">With unit</div>
```
## Images
Use `NgOptimizedImage` for static images:
```typescript
import { NgOptimizedImage } from "@angular/common";
@Component({
imports: [NgOptimizedImage],
template: `
<img ngSrc="/assets/hero.jpg" width="800" height="600" priority />
<img [ngSrc]="imageUrl()" width="200" height="200" />
`,
})
export class Hero {
imageUrl = input.required<string>();
}
```
For detailed patterns, see [references/component-patterns.md](references/component-patterns.md).
@@ -0,0 +1,358 @@
# Angular Component Patterns
## Table of Contents
- [Model Inputs (Two-Way Binding)](#model-inputs-two-way-binding)
- [View Queries](#view-queries)
- [Content Queries](#content-queries)
- [Dependency Injection in Components](#dependency-injection-in-components)
- [Component Communication Patterns](#component-communication-patterns)
- [Dynamic Components](#dynamic-components)
## Model Inputs (Two-Way Binding)
For two-way binding with `[(value)]` syntax:
```typescript
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:
```typescript
value = model.required<number>();
```
## View Queries
Query elements and components in the template:
```typescript
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:
```typescript
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:
```typescript
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)
```typescript
// 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)
```typescript
// 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
```typescript
// 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:
```typescript
@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
```typescript
@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
```typescript
@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
```typescript
@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);
}
}
```