src/skills/web-framework-angular-standalone/SKILL.md
Angular 17-19 standalone components, signals, control flow, dependency injection patterns
npx skillsauth add agents-inc/skills web-framework-angular-standaloneInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Quick Guide: Components are standalone by default in Angular 19. Use
signal(),computed(),effect(),linkedSignal()for reactive state. Useinput(),output(),model()for component communication. Use@if,@for,@switch,@deferfor template control flow. Useinject()for dependency injection. Useresource()for async data fetching.
<critical_requirements>
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST write standalone components (the default in Angular 19) - only specify standalone: false when intentionally using NgModules)
(You MUST use input(), output(), model() functions instead of @Input(), @Output() decorators)
(You MUST use inject() function for dependency injection, NOT constructor injection)
(You MUST use @if, @for, @switch control flow blocks, NOT *ngIf, *ngFor, *ngSwitch)
(You MUST use track expression in ALL @for loops)
(You MUST use linkedSignal() instead of manual signal synchronization for dependent writable state)
</critical_requirements>
Auto-detection: Angular component, standalone component, signal, computed, effect, linkedSignal, resource, rxResource, httpResource, input(), output(), model(), @if, @for, @switch, @defer, inject(), provideRouter, afterRenderEffect
When to use:
@defer or loadComponentresource(), rxResource(), or httpResource()Key patterns covered:
When NOT to use:
Detailed Resources:
Angular 17-19 embraces a standalone-first architecture that eliminates NgModule boilerplate. In Angular 19, standalone: true is the default - you only need to specify standalone: false for NgModule components. Signals provide synchronous, fine-grained reactivity for predictable state management. The new control flow syntax (@if, @for, @switch, @defer) is built into templates without imports, offering better type narrowing and smaller bundles. Components should be self-contained, lazy-loadable units that declare their own dependencies.
Angular's Four Pillars (17-19):
signal(), computed(), linkedSignal()resource(), rxResource(), httpResource() in 19.2)All Angular 17-19 components use standalone: true (the default in Angular 19) and declare their own imports.
// 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>();
}
Why good: standalone: true eliminates NgModule boilerplate, imports array declares dependencies explicitly for tree-shaking, signal-based input() and output() provide type-safe reactive communication, template is colocated for readability
// BAD - Legacy patterns
@Component({
selector: "app-user-card",
template: `...`,
})
export class UserCardComponent {
@Input() user!: User; // Legacy decorator
@Output() edit = new EventEmitter<User>(); // Legacy EventEmitter
}
Why bad: @Input decorator lacks signal reactivity, EventEmitter is less type-safe than output(), non-null assertion (!) hides potential undefined errors, no imports array means dependencies aren't explicit
Use signal() for writable state, computed() for derived values, and effect() for side effects. Key rules: always use .set() or .update() for mutations (never mutate the value directly), use computed() for derived values (not methods), and reserve effect() for true side effects (logging, analytics, localStorage).
// 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()}`));
See examples/core.md for a full shopping cart example with signals.
// 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(...); }
Why bad: direct mutation doesn't trigger change detection, returning same reference skips equality check, methods lack memoization that computed() provides
Use input(), output(), and model() functions for component communication.
// 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("");
}
}
Usage in parent:
<app-search-input
[minLength]="3"
[(query)]="searchQuery"
(search)="onSearch($event)"
/>
Why good: input() and input.required() clearly distinguish optional vs required props, model() enables two-way binding with [(query)] syntax, computed() derives validation state reactively, output() provides type-safe event emission
Use built-in control flow blocks instead of structural directives.
// 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>();
}
Why good: @switch provides clear multi-branch logic, @for with track enables efficient DOM updates, @empty handles empty collections elegantly, $index/$first/$last provide iteration context without extra code, no CommonModule import required
// 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
}
}
Why bad: requires CommonModule import, trackBy requires separate function, ng-template syntax is verbose, less optimal type narrowing
Use @defer for lazy loading components and improving initial bundle size.
// 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);
}
Why good: @defer reduces initial bundle size by lazy-loading components, @placeholder prevents layout shift during load, @loading shows progress after delay to avoid flicker, @error handles failures gracefully, prefetch optimizes perceived performance
When to use @defer:
When NOT to use @defer:
Use inject() function instead of constructor injection for cleaner, more flexible DI.
// 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>;
},
});
}
Why good: inject() provides cleaner syntax without constructor boilerplate, resource() handles loading/error states and race conditions automatically, no manual signal + effect combo needed
// BAD - Constructor injection (legacy)
export class UserProfileComponent {
constructor(
private route: ActivatedRoute,
private userService: UserService,
) {}
}
Why bad: constructor injection requires boilerplate, doesn't work in field initializers, less flexible for conditional injection
inject() with options:
// 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 });
Configure routing using provideRouter and lazy load with loadComponent.
// 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>();
}
Why good: provideRouter replaces RouterModule.forRoot(), loadComponent lazy loads individual components without wrapper modules, withComponentInputBinding eliminates ActivatedRoute boilerplate, preloading improves navigation performance
Replace traditional lifecycle hooks with signal-based patterns.
// 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();
});
}
}
Why good: afterNextRender runs after first render for DOM setup, afterRender provides per-render hooks, DestroyRef.onDestroy handles cleanup without implementing OnDestroy, signals automatically trigger change detection
Lifecycle hook mapping:
| 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+) |
</patterns>Angular standalone architecture is self-contained. Components declare their own imports and providers. Routing uses provideRouter. Services use providedIn: "root" or component-level providers.
Bootstrapping:
// 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),
);
Component Communication:
input() and input.required()output() with .emit()model() with [()] syntaxinject()RxJS Interop:
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);
</integration>
<red_flags>
High Priority:
input(), output(), model() signal functions@if, @for, @switch built-in control flowtrack in @for - Causes unnecessary DOM recreation and poor performancesignal().push(item) doesn't trigger updates; use .update() with spreadlinkedSignal() for writable derived state (v19+)resource()/rxResource()/httpResource() are read-only; use HttpClient for POST/PUT/DELETEMedium Priority:
computed() or linkedSignal() insteadafterRenderEffect() with phaseshasValue() as type guard before accessing value()Gotchas & Edge Cases:
signal() uses Object.is() equality by default; provide custom equality for objectsinject() must be called in constructor or field initializer, not in methods@defer always renders @placeholder on server (SSR); triggers are ignored server-sidelinkedSignal() value resets when source signal changes; use computation form to preserve previousafterRenderEffect() without phase specification defaults to mixedReadWrite which can cause layout thrashingSee reference.md for complete decision frameworks, anti-patterns with code examples, and quick reference tables.
</red_flags>
<critical_reminders>
All code must follow project conventions in CLAUDE.md
(You MUST write standalone components (the default in Angular 19) - only specify standalone: false when intentionally using NgModules)
(You MUST use input(), output(), model() functions instead of @Input(), @Output() decorators)
(You MUST use inject() function for dependency injection, NOT constructor injection)
(You MUST use @if, @for, @switch control flow blocks, NOT *ngIf, *ngFor, *ngSwitch)
(You MUST use track expression in ALL @for loops)
(You MUST use linkedSignal() instead of manual signal synchronization for dependent writable state)
Failure to follow these rules will produce legacy Angular code that misses performance optimizations and modern reactivity benefits.
</critical_reminders>
development
Material Design component library for Vue 3
development
VitePress 1.x — Vue-powered static site generator for documentation sites, built on Vite
tools
Docusaurus 3.x documentation framework — site configuration, docs/blog plugins, sidebars, versioning, MDX, swizzling, and deployment
development
TanStack Form patterns - useForm, form.Field, validators, arrays, linked fields, createFormHook, type safety