Loading...
Loading...
Angular 17-19 standalone components, signals, control flow, dependency injection patterns
npx skill4agent add agents-inc/skills web-framework-angular-standaloneQuick Guide: Components are standalone by default in Angular 19. Use,signal(),computed(),effect()for reactive state. UselinkedSignal(),input(),output()for component communication. Usemodel(),@if,@for,@switchfor template control flow. Use@deferfor dependency injection. Useinject()for async data fetching.resource()
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,, named constants)import type
standalone: falseinput()output()model()@Input()@Output()inject()@if@for@switch*ngIf*ngFor*ngSwitchtrack@forlinkedSignal()@deferloadComponentresource()rxResource()httpResource()standalone: truestandalone: false@if@for@switch@defersignal()computed()linkedSignal()resource()rxResource()httpResource()standalone: true// user-card.component.ts
import { Component, input, output } from "@angular/core";
import { DatePipe } from "@angular/common";
export type User = {
id: string;
name: string;
email: string;
createdAt: Date;
};
@Component({
selector: "app-user-card",
standalone: true,
imports: [DatePipe],
template: `
<article class="user-card">
<h2>{{ user().name }}</h2>
<p>{{ user().email }}</p>
<time>Joined: {{ user().createdAt | date: "mediumDate" }}</time>
<button (click)="edit.emit(user())">Edit</button>
</article>
`,
})
export class UserCardComponent {
// Signal-based input (required)
user = input.required<User>();
// Signal-based output
edit = output<User>();
}// BAD - Legacy patterns
@Component({
selector: "app-user-card",
template: `...`,
})
export class UserCardComponent {
@Input() user!: User; // Legacy decorator
@Output() edit = new EventEmitter<User>(); // Legacy EventEmitter
}signal()computed()effect().set().update()computed()effect()// Writable signal
count = signal(0);
// Computed signal (read-only, memoized, recalculates only when deps change)
doubleCount = computed(() => this.count() * 2);
// Updating signals - always immutable
this.count.set(5); // Replace value
this.count.update((value) => value + 1); // Update from previous
// For arrays/objects: return new references
items = signal<Item[]>([]);
this.items.update((items) => [...items, newItem]); // Spread, don't push
// Effect for side effects only (not derived state)
effect(() => console.log(`Count: ${this.count()}`));// BAD - Direct mutation doesn't trigger reactivity
this.items().push(newItem); // signal won't notify consumers
this.items.update(items => { items.push(newItem); return items; }); // same reference, no update
// BAD - Method instead of computed (recalculates every call, not memoized)
getTotal(): number { return this.items().reduce(...); }input()output()model()// search-input.component.ts
import { Component, input, output, model, computed } from "@angular/core";
const MIN_SEARCH_LENGTH = 3;
@Component({
selector: "app-search-input",
standalone: true,
template: `
<div class="search-input">
<input
[value]="query()"
(input)="onInput($event)"
[placeholder]="placeholder()"
/>
@if (isValidSearch()) {
<button (click)="search.emit(query())">Search</button>
}
@if (query()) {
<button (click)="clear()">Clear</button>
}
</div>
`,
})
export class SearchInputComponent {
// Optional input with default value
placeholder = input("Search...");
// Required input
minLength = input.required<number>();
// Two-way binding with model()
query = model("");
// Output event
search = output<string>();
// Computed from inputs
isValidSearch = computed(() => this.query().length >= this.minLength());
onInput(event: Event): void {
const target = event.target as HTMLInputElement;
this.query.set(target.value);
}
clear(): void {
this.query.set("");
}
}<app-search-input
[minLength]="3"
[(query)]="searchQuery"
(search)="onSearch($event)"
/>// user-list.component.ts
import { Component, input, output } from "@angular/core";
import type { User } from "./user.types";
type LoadingState = "idle" | "loading" | "error" | "success";
@Component({
selector: "app-user-list",
standalone: true,
template: `
@switch (state()) {
@case ("loading") {
<div class="loading">Loading users...</div>
}
@case ("error") {
<div class="error">
<p>Failed to load users</p>
<button (click)="retry.emit()">Retry</button>
</div>
}
@case ("success") {
@if (users().length > 0) {
<ul class="user-list">
@for (
user of users();
track user.id;
let i = $index, first = $first, last = $last
) {
<li [class.first]="first" [class.last]="last">
<span class="index">{{ i + 1 }}.</span>
<span class="name">{{ user.name }}</span>
<span class="email">{{ user.email }}</span>
</li>
} @empty {
<li class="empty">No users found</li>
}
</ul>
} @else {
<p>No users available</p>
}
}
@default {
<p>Ready to load users</p>
}
}
`,
})
export class UserListComponent {
users = input.required<User[]>();
state = input<LoadingState>("idle");
retry = output<void>();
}// BAD - Legacy structural directives
@Component({
imports: [CommonModule], // Extra import needed
template: `
<div *ngIf="loading; else content">Loading...</div>
<ng-template #content>
<ul>
<li *ngFor="let user of users; trackBy: trackByFn; let i = index">
{{ user.name }}
</li>
</ul>
</ng-template>
`,
})
export class UserListComponent {
trackByFn(index: number, user: User): string {
return user.id; // Separate function needed
}
}@defer// dashboard.component.ts
import { Component, signal } from "@angular/core";
@Component({
selector: "app-dashboard",
standalone: true,
template: `
<h1>Dashboard</h1>
<!-- Defer loading until viewport -->
@defer (on viewport) {
<app-heavy-chart />
} @placeholder (minimum 200ms) {
<div class="chart-skeleton">Chart loading...</div>
} @loading (after 100ms; minimum 500ms) {
<div class="spinner">Loading chart...</div>
} @error {
<div class="error">Failed to load chart</div>
}
<!-- Defer loading on interaction -->
@defer (on interaction) {
<app-comments-section />
} @placeholder {
<button>Load Comments</button>
}
<!-- Defer with condition -->
@defer (when showAdvanced()) {
<app-advanced-settings />
} @placeholder {
<p>Advanced settings will load when enabled</p>
}
<!-- Prefetch for faster navigation -->
@defer (on idle; prefetch on hover) {
<app-related-items />
} @placeholder {
<div class="related-skeleton">Related items</div>
}
`,
})
export class DashboardComponent {
showAdvanced = signal(false);
}inject()// user.service.ts
import { Injectable, inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import type { User } from "./user.types";
const API_BASE_URL = "/api";
@Injectable({ providedIn: "root" })
export class UserService {
private http = inject(HttpClient);
getUsers() {
return this.http.get<User[]>(`${API_BASE_URL}/users`);
}
getUser(id: string) {
return this.http.get<User>(`${API_BASE_URL}/users/${id}`);
}
}// user-profile.component.ts
import { Component, inject, resource } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { toSignal } from "@angular/core/rxjs-interop";
import type { User } from "./user.types";
const API_BASE_URL = "/api";
@Component({
selector: "app-user-profile",
standalone: true,
template: `
@if (userResource.isLoading()) {
<p>Loading user...</p>
}
@if (userResource.hasValue()) {
<h1>{{ userResource.value().name }}</h1>
<p>{{ userResource.value().email }}</p>
}
@if (userResource.error(); as error) {
<p>Error: {{ error }}</p>
<button (click)="userResource.reload()">Retry</button>
}
`,
})
export class UserProfileComponent {
private route = inject(ActivatedRoute);
// Convert route params to signal
private params = toSignal(this.route.params, { initialValue: { id: "" } });
// resource() auto-refetches when userId changes
userResource = resource({
params: () => ({ id: this.params()["id"] }),
loader: async ({ params, abortSignal }) => {
const response = await fetch(`${API_BASE_URL}/users/${params.id}`, {
signal: abortSignal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<User>;
},
});
}// BAD - Constructor injection (legacy)
export class UserProfileComponent {
constructor(
private route: ActivatedRoute,
private userService: UserService,
) {}
}// Optional injection
private optionalService = inject(OptionalService, { optional: true });
// Skip self (look in parent injectors)
private parentService = inject(ParentService, { skipSelf: true });
// Self only (don't look in parent injectors)
private selfService = inject(SelfService, { self: true });provideRouterloadComponent// app.config.ts
import { ApplicationConfig } from "@angular/core";
import {
provideRouter,
withComponentInputBinding,
withPreloading,
PreloadAllModules,
} from "@angular/router";
import { provideHttpClient } from "@angular/common/http";
import { routes } from "./app.routes";
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(
routes,
withComponentInputBinding(), // Bind route params to inputs
withPreloading(PreloadAllModules), // Preload lazy routes
),
provideHttpClient(),
],
};// app.routes.ts
import type { Routes } from "@angular/router";
export const routes: Routes = [
{
path: "",
loadComponent: () =>
import("./home/home.component").then((m) => m.HomeComponent),
},
{
path: "users",
loadComponent: () =>
import("./users/user-list.component").then((m) => m.UserListComponent),
},
{
path: "users/:id",
loadComponent: () =>
import("./users/user-detail.component").then(
(m) => m.UserDetailComponent,
),
},
{
path: "admin",
loadComponent: () =>
import("./admin/admin.component").then((m) => m.AdminComponent),
canActivate: [authGuard],
},
{
path: "**",
loadComponent: () =>
import("./not-found/not-found.component").then(
(m) => m.NotFoundComponent,
),
},
];// user-detail.component.ts - Using withComponentInputBinding
import { Component, input } from "@angular/core";
@Component({
selector: "app-user-detail",
standalone: true,
template: `
<h1>User {{ id() }}</h1>
@if (tab()) {
<p>Active tab: {{ tab() }}</p>
}
`,
})
export class UserDetailComponent {
// Route param :id bound automatically with withComponentInputBinding
id = input.required<string>();
// Query param ?tab bound automatically
tab = input<string | undefined>();
}// resize-observer.component.ts
import {
Component,
ElementRef,
signal,
inject,
afterNextRender,
afterRender,
DestroyRef,
} from "@angular/core";
const DEBOUNCE_MS = 100;
@Component({
selector: "app-resize-observer",
standalone: true,
template: `
<div #container class="container">
<p>Width: {{ width() }}px</p>
<p>Height: {{ height() }}px</p>
</div>
`,
})
export class ResizeObserverComponent {
private elementRef = inject(ElementRef);
private destroyRef = inject(DestroyRef);
width = signal(0);
height = signal(0);
constructor() {
// Run once after first render (replaces ngAfterViewInit for DOM setup)
afterNextRender(() => {
this.setupResizeObserver();
});
// Run after every render (use sparingly)
afterRender(() => {
console.log("Component rendered");
});
}
private setupResizeObserver(): void {
const element = this.elementRef.nativeElement;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
this.width.set(entry.contentRect.width);
this.height.set(entry.contentRect.height);
}
});
observer.observe(element);
// Cleanup on destroy (replaces ngOnDestroy)
this.destroyRef.onDestroy(() => {
observer.disconnect();
});
}
}| Legacy Hook | Signal-Based Alternative |
|---|---|
| ngOnInit | constructor + effect() |
| ngOnChanges | effect() watching input() signals |
| ngAfterViewInit | afterNextRender() |
| ngAfterViewChecked | afterRender() (afterEveryRender() in v20+) |
| ngOnDestroy | DestroyRef.onDestroy() |
| DOM side effects | afterRenderEffect() with phases (v19+) |
provideRouterprovidedIn: "root"// main.ts
import { bootstrapApplication } from "@angular/platform-browser";
import { AppComponent } from "./app/app.component";
import { appConfig } from "./app/app.config";
bootstrapApplication(AppComponent, appConfig).catch((err) =>
console.error(err),
);input()input.required()output().emit()model()[()]inject()import { toSignal, toObservable } from "@angular/core/rxjs-interop";
// Observable to Signal
const users = toSignal(this.userService.getUsers(), { initialValue: [] });
// Signal to Observable
const count$ = toObservable(this.count);input()output()model()@if@for@switchtracksignal().push(item).update()linkedSignal()resource()rxResource()httpResource()computed()linkedSignal()afterRenderEffect()hasValue()value()signal()Object.is()inject()@defer@placeholderlinkedSignal()afterRenderEffect()mixedReadWriteAll code must follow project conventions in CLAUDE.md
standalone: falseinput()output()model()@Input()@Output()inject()@if@for@switch*ngIf*ngFor*ngSwitchtrack@forlinkedSignal()