angular
Use when building, refactoring, or debugging Angular (v20/21+): standalone components, signals, zoneless change detection, @if/@for/@defer control flow, inject() DI, resource()/httpResource(), RxJS interop, NgRx SignalStore, ng CLI. NOT React (that is react), NOT Next.js (that is
#typescript
Install
npx skills add https://github.com/ericrisco/rsc-harness/tree/main/skills/angular
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install ericrisco-rsc-harness@llmmart
git clone https://github.com/ericrisco/rsc-harness.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole ericrisco/rsc-harness collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Angular — Standalone, Signals, Zoneless (Angular 20/21+)
Build Angular the way it ships in 2026: standalone components, signals as the reactivity model, zoneless change detection, built-in control flow, and
inject()DI. Treat NgModules,*ngFor, and@Input()decorators as legacy you only touch to migrate.
Not this skill
AngularJS (1.x) is out of scope entirely — this skill is Angular 2+ only and the APIs do not map.
Route elsewhere for React → ../react/SKILL.md; Next.js App Router → ../nextjs/SKILL.md;
Vue/Nuxt, Svelte, SolidJS, Astro → ../vue-nuxt/SKILL.md, ../svelte/SKILL.md,
../solid-js/SKILL.md, ../astro/SKILL.md; a pure TypeScript language question (generics,
narrowing, tsconfig) with no Angular dimension → ../typescript/SKILL.md; a standalone NestJS
API → ../nestjs/SKILL.md; a generic Node service → ../nodejs/SKILL.md; cross-framework
Playwright e2e strategy → ../testing-web/SKILL.md / ../e2e-testing/SKILL.md. Angular Universal
SSR and Angular's own ng test (Vitest) setup stay here.
Decide first
| Situation | Do this | Why |
|---|---|---|
| Greenfield app / new feature | Zoneless + signals + standalone by default. ng new (Angular 21) already excludes Zone.js. |
The defaults shipped stable in v20-v21; fight them and you write more code that the framework now does for you. |
| Brownfield NgModule + decorator app | Migrate incrementally with the schematics in references/migration.md (NgModule→standalone, control flow, decorator→signal inputs, Zone.js→zoneless, Karma→Vitest); do not rewrite. Keep Zone.js until you flip it on purpose. |
A working app that uses *ngIf is not a bug. Churn introduces risk for no user value. |
| "View not updating" complaint | Jump to the change-detection section: signal not read in template, OnPush without a signal, or stale Zone.js assumption. |
Zoneless means a mutation that no signal observes will never repaint — the fix is structural, not a detectChanges() call. |
The modern baseline
No NgModules. Bootstrap a standalone root component and configure providers in app.config.ts.
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig } from './app/app.config';
bootstrapApplication(App, appConfig);
// app/app.config.ts
import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(), // no Zone.js; CD driven by signals + events
provideRouter(routes),
provideHttpClient(withFetch()),
],
};
- Rule: one
bootstrapApplicationcall, providers inapp.config.ts. Why: NgModule bootstrap (platformBrowserDynamic().bootstrapModule(AppModule)) is the legacy path — more files, slower to reason about. - Rule: components are
standaloneby default (thestandaloneflag is implied in v20+; do not writestandalone: truein new code, and never writestandalone: false). Why: standalone is the framework default now; the flag is noise.
Bad → Good
// Bad — NgModule wiring for a single component
@NgModule({ declarations: [UserCard], imports: [CommonModule], exports: [UserCard] })
export class UserCardModule {}
// Good — standalone component imports only what it uses
@Component({
selector: 'app-user-card',
imports: [DatePipe],
template: `<p>{{ joined() | date }}</p>`,
})
export class UserCard {
joined = input.required<Date>();
}
Signals as the reactivity model
signal() holds state, computed() derives it, effect() runs side effects, linkedSignal() resets writable state when a source changes.
import { signal, computed, effect, linkedSignal } from '@angular/core';
const qty = signal(1);
const price = signal(9.99);
const total = computed(() => qty() * price()); // derived — recomputes lazily
const draftQty = linkedSignal(() => qty()); // writable, resets when qty changes
effect(() => console.log('total changed:', total())); // side effect ONLY (logging, DOM, sync)
- Rule: derive with
computed(), never witheffect(). Why: aneffect()that writes a signal to "compute" a value creates a hidden dependency graph that loops or fires extra times —computed()is pull-based and memoized. - Rule:
effect()is for side effects (logging,localStorage, imperative DOM, 3rd-party libs), not for keeping two signals in sync. Why: synced state belongs incomputed()orlinkedSignal().
Component I/O is signal-based: input(), input.required(), output(), model() for two-way.
Bad → Good
// Bad — decorator I/O, mutable, no type-safety on required
@Input() userId!: string;
@Output() saved = new EventEmitter<User>();
// Good — signal inputs/outputs
userId = input.required<string>(); // read as userId()
saved = output<User>(); // emit with saved.emit(user)
name = model(''); // two-way: [(name)]="..."
Templates: built-in control flow
Use @if / @for / @switch / @defer. The legacy *ngIf / *ngFor / *ngSwitch structural directives are deprecated.
@if (user(); as u) {
<h1>{{ u.name }}</h1>
} @else {
<app-spinner />
}
@for (item of items(); track item.id) {
<li>{{ item.label }}</li>
} @empty {
<li>No items</li>
}
@defer (on viewport) {
<app-heavy-chart [data]="rows()" />
} @placeholder {
<div class="skeleton"></div>
}
- Rule: every
@formust declaretrack. Why: it is required syntax (the template won't compile without it) and it controls DOM reuse —track item.idovertrack $indexwhen items have stable identity, or the DOM thrashes on reorder. - Rule: reach for
@deferto lazy-load heavy sub-trees and enable incremental hydration. Why: it ships less JS up front without manualloadComponentplumbing.
Bad → Good
<!-- Bad — legacy structural directive, no tracking -->
<li *ngFor="let item of items">{{ item.label }}</li>
<!-- Good — built-in control flow with track -->
@for (item of items(); track item.id) { <li>{{ item.label }}</li> }
Data fetching
Default to signal-based resources; reach for HttpClient + RxJS only when you need streams, cancellation, or operator composition.
import { httpResource } from '@angular/common/http';
import { resource } from '@angular/core';
// httpResource — declarative GET wired to HttpClient; reactive to its URL signal
users = httpResource<User[]>(() => `/api/users?team=${this.team()}`);
// template: @if (users.isLoading()) {…} @else { @for (u of users.value(); track u.id) {…} }
// users.error() -> error signal; users.reload() -> refetch
// resource — any async loader (not just HTTP)
profile = resource({
params: () => ({ id: this.userId() }),
loader: ({ params }) => fetchProfile(params.id),
});
- Rule:
httpResource()/resource()give youvalue(),isLoading(),error(),reload()for free — prefer them over a manualsubscribethat you have to clean up. Why: less boilerplate, no leak, refetches automatically when its source signals change. - Rule: when you genuinely need a stream (websocket, debounced search, retry/switchMap), keep
HttpClient+ RxJS and bridge to a signal withtoSignal(). Why: signals are not streams; do not fake backpressure with effects.references/signals-rxjs.mdhas the signals-vs-RxJS decision matrix,toSignal/toObservableinterop recipes,effectpitfalls (infinite loops, untracked reads), andtakeUntilDestroyed.
DI & services
@Injectable({ providedIn: 'root' })
export class UserApi {
private http = inject(HttpClient); // field initializer — no constructor needed
list = () => this.http.get<User[]>('/api/users');
}
- Rule: inject with
inject(), not constructor parameters. Why:inject()works in field initializers and composes into plain functions (guards, factories); constructor DI is the legacy ergonomic. - Rule:
providedIn: 'root'for app-wide singletons. Why: tree-shakable — unused services drop from the bundle. - Rule: HTTP cross-cutting concerns are functional interceptors:
provideHttpClient(withInterceptors([authInterceptor])). Why: class interceptors withHTTP_INTERCEPTORSare the older multi-provider pattern.
Routing
// app.routes.ts
export const routes: Routes = [
{ path: 'users', loadComponent: () => import('./users/users-list').then(m => m.UsersList) },
{ path: 'users/:id', loadComponent: () => import('./users/user-detail').then(m => m.UserDetail),
canActivate: [authGuard] },
];
export const authGuard: CanActivateFn = () => inject(AuthService).isLoggedIn();
Enable route-bound signal inputs with withComponentInputBinding() in provideRouter, then read route params as signal inputs:
provideRouter(routes, withComponentInputBinding());
// in UserDetail: id = input.required<string>(); // bound from the :id segment
- Rule: lazy-load routes with
loadComponent(orloadChildrenwith a routes array). Why: smaller initial bundle, no NgModule needed. - Rule: guards/resolvers are functions (
CanActivateFn,ResolveFn) usinginject(). Why: class-based guards are deprecated.
State
- Local/feature state → a signal service (
@Injectableholdingsignal/computed). Simple, no library. - App-wide state → NgRx SignalStore (
signalStore,withState,withComputed,withMethods,withProps) — signals-native, pairs cleanly withresource().
export const CartStore = signalStore(
{ providedIn: 'root' },
withState({ items: [] as Item[] }),
withComputed(({ items }) => ({ count: computed(() => items().length) })),
withMethods((store) => ({ add: (i: Item) => patchState(store, s => ({ items: [...s.items, i] })) })),
);
- Note: Signal Forms is experimental (prototype since Angular 21.0.0-next.2). For production forms use reactive/typed forms (
FormGroup/FormControlwith typed values). Why: do not ship a prototype API to users.
CLI workflow
ng new my-app # Angular 21: zoneless + standalone + Vitest by default
ng generate component user-card # standalone by default; no --standalone flag needed
ng generate service user-api
ng build # production build
ng test # Vitest (default runner in v21; Karma is deprecated)
ng update @angular/core @angular/cli # version bumps + automated migrations
Testing
Use Vitest + TestBed. Provide zoneless CD in tests and set signal inputs via componentRef.
import { TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
it('renders the user name', async () => {
TestBed.configureTestingModule({
providers: [provideZonelessChangeDetection()],
});
const fixture = TestBed.createComponent(UserCard);
fixture.componentRef.setInput('joined', new Date('2026-01-01'));
await fixture.whenStable(); // not detectChanges() — let CD settle
expect(fixture.nativeElement.textContent).toContain('2026');
});
- Rule: set signal inputs with
fixture.componentRef.setInput('name', value), never by poking the instance field. Why:setInputflows through the input pipeline and marks the view dirty. - Rule: prefer
await fixture.whenStable()over manualdetectChanges()loops under zoneless. Why: it waits for the scheduler to flush instead of forcing a single synchronous pass.
Anti-patterns
| Bad | Why it's wrong | Good |
|---|---|---|
@NgModule in new code |
Standalone is the default; modules add ceremony and slow analysis | Standalone component with an imports: [] array |
*ngIf / *ngFor / *ngSwitch |
Legacy structural directives; deprecated | @if / @for (… ; track id) / @switch |
@Input() / @Output() decorators |
No required-input safety, not signal-reactive | input() / input.required() / output() / model() |
effect(() => this.total.set(a()*b())) |
Effect-to-derive-state loops and double-fires | total = computed(() => a()*b()) |
@for without track |
Won't compile; if forced, DOM thrashes on reorder | track item.id (stable identity) |
subscribe() in a component with no teardown |
Memory leak; runs after the view is destroyed | toSignal() or takeUntilDestroyed() |
ChangeDetectorRef.detectChanges() to "fix" a stale view |
Masks the real cause under zoneless | Read the value through a signal so CD tracks it |
Nested subscribe() inside subscribe() |
Callback pyramid, lost cancellation | switchMap/concatMap, one subscription |
Constructor DI only (constructor(private x: X)) |
Legacy ergonomic; can't compose into functions | private x = inject(X) |
scripts/verify.sh is a heuristic copy-banlist lint — it greps your Angular sources for the banned patterns above. It is a hint, not a compiler.
Files (rsc-harness)
-
evals
-
cases.yaml 2 KB
skill: angular should_trigger: - prompt: "Migrate this Angular app from NgModules to standalone components." why: "Standalone migration is central modern Angular work." - prompt: "Why is my Angular view not updating in zoneless mode?" why: "Zoneless change detection and signals are Angular-specific." - prompt: "Replace *ngFor with @for and track expressions." why: "Built-in control flow migration belongs to Angular." - prompt: "Use input() signals instead of decorator @Input in this component." why: "Signal inputs are Angular-specific." - prompt: "Monta una app Angular con componente standalone y signals." why: "Spanish Angular standalone/signals request triggers this skill." should_not_trigger: - prompt: "Fix a React hook state bug." route_to: "react" why: "React hooks are not Angular." - prompt: "Implement a Next.js server component." route_to: "nextjs" why: "Next.js App Router/RSC is a separate stack." - prompt: "Explain TypeScript conditional types." route_to: "typescript" why: "Pure TypeScript without Angular belongs to TypeScript." - prompt: "Build a NestJS backend module." route_to: "nestjs" why: "NestJS is backend DI; Angular is frontend framework." capability: - scenario: "A brownfield Angular app uses NgModules, decorator inputs and Zone.js; the user wants a safe modern migration." must_include: - "Detects Angular version and brownfield vs greenfield before changing architecture." - "Migrates incrementally to standalone components rather than rewriting everything." - "Uses signals/input() and built-in @if/@for/@defer control flow where appropriate." - "Explains zoneless change detection and avoids detectChanges() as a default patch." - "Keeps providers in app.config.ts/provide* APIs for modern bootstrap." - "Uses RxJS interop deliberately with toSignal/toObservable when needed." - "Routes pure TypeScript or backend NestJS questions to those skills." -
README.md 72 B
# angular evals Trigger and capability checks for modern Angular work.
-
-
references
-
migration.md 4 KB
# Incremental migration — legacy Angular → standalone, signals, zoneless Migrate a working app in small reversible steps. Each step has an official schematic; run them one at a time, commit, and verify the app still builds and tests pass before the next. ## Order of operations 1. Update Angular first, then migrate. `ng update @angular/core @angular/cli` runs the bundled migrations for the version you land on. Do not migrate APIs on an old major. 2. Standalone components/directives/pipes. 3. Built-in control flow (`*ngIf`/`*ngFor` → `@if`/`@for`). 4. Signal inputs/outputs. 5. Zoneless (last — it has the widest blast radius). 6. Test runner (Karma/Jasmine → Vitest), independently of the above. ## 1. NgModule → standalone ```bash ng generate @angular/core:standalone ``` Run it three times, choosing each mode in turn: - *Convert all components, directives and pipes to standalone* - *Remove unnecessary NgModule classes* - *Bootstrap the application using standalone APIs* (rewrites `main.ts` to `bootstrapApplication`) After the last pass you should have an `app.config.ts` with `provideRouter`, `provideHttpClient`, etc. Delete the now-empty `AppModule`. ## 2. Control flow: *ngIf / *ngFor → @if / @for ```bash ng generate @angular/core:control-flow ``` This rewrites templates automatically. Review every converted `@for`: the schematic inserts a `track` expression (often `track $index` as a safe default). Replace `$index` with a stable key (`track item.id`) wherever items have identity, so the DOM reuses nodes on reorder. Once converted you can drop `CommonModule` imports that only existed for the directives. ## 3. Decorator inputs/outputs → signals ```bash ng generate @angular/core:signal-input-migration ng generate @angular/core:output-migration ng generate @angular/core:signal-queries-migration # @ViewChild/@ContentChild -> viewChild()/contentChild() ``` What changes for callers inside the class: - `this.userId` (a value) becomes `this.userId()` (a signal read). The migration updates template and class reads, but check any code that assigned to the field — signal inputs are read-only; lift writable state into a separate `signal()` or use `model()` for two-way. - `@Output() saved = new EventEmitter()` → `saved = output()`, emit with `saved.emit(x)`. ## 4. Zone.js → zoneless go-live checklist Flip this last, after signals and control flow are in place. - [ ] Add `provideZonelessChangeDetection()` to the app providers (and to test providers). - [ ] Remove `provideZoneChangeDetection()` if present. - [ ] Delete the `zone.js` import from `polyfills`/`main.ts`; remove `zone.js` from `package.json` and the `polyfills` entry in `angular.json`. - [ ] Audit every component for state read in the template that is **not** a signal — under zoneless, a plain field mutated outside an event handler will not repaint. Convert it to a `signal()`, or ensure the change originates from a tracked source (signal, async pipe, template event, router). - [ ] Replace any `setTimeout`/`Promise`/3rd-party-callback that mutates view state with a signal write, or call `ChangeDetectorRef.markForCheck()` from inside it. - [ ] Search for `ChangeDetectorRef.detectChanges()` "fix-it" calls and the `NgZone.run()` escape hatch — both are smells that some state isn't a signal yet. - [ ] Run the app and click through; watch for views that update only on the *next* unrelated interaction (the classic zoneless-miss symptom). ## 5. Karma/Jasmine → Vitest New Angular 21 projects use Vitest by default. For an existing project, switch the test builder to the Vitest-based runner in `angular.json` and migrate specs: Jasmine's `spyOn`/`jasmine. createSpy` map to Vitest's `vi.fn`/`vi.spyOn`, and `expect().toHaveBeenCalled()` etc. are compatible. Keep `TestBed`; only the runner and the mocking API change. Add `provideZonelessChangeDetection()` to test module providers and prefer `await fixture.whenStable()` over manual `detectChanges()` loops. -
signals-rxjs.md 4 KB
# Signals vs RxJS — when to use which, and how to bridge Signals and RxJS coexist in modern Angular. Signals are the default for UI state and synchronous derivation; RxJS still owns asynchronous *streams* of events over time. ## Decision matrix | You have… | Use | Why | |-----------|-----|-----| | A piece of UI state read in a template | `signal()` | Pull-based, glitch-free, drives zoneless CD directly | | A value derived from other state | `computed()` | Memoized, lazy, no manual subscription | | One async fetch tied to inputs | `httpResource()` / `resource()` | Gives `value/isLoading/error/reload`, auto-refetch, no leak | | A stream over time (websocket, key events, intervals) | RxJS `Observable` | Signals are values-now, not events-over-time | | Debounce / throttle / retry / cancel-in-flight | RxJS (`debounceTime`, `switchMap`, `retry`) | These are stream operators; signals have no time dimension | | A writable copy that resets when a source changes | `linkedSignal()` | Purpose-built; avoids an effect-to-sync anti-pattern | Rule of thumb: **state → signals, events → observables.** When in doubt, start with a signal; escalate to RxJS only when you need an operator that models time. ## Interop `toSignal()` and `toObservable()` are the two bridges (`@angular/core/rxjs-interop`). ```typescript import { toSignal, toObservable, takeUntilDestroyed } from '@angular/core/rxjs-interop'; // Observable -> Signal: subscribe is managed for you; unsubscribes on destroy. private search = signal(''); results = toSignal( toObservable(this.search).pipe( debounceTime(300), switchMap(q => this.api.search(q)), // switchMap cancels the prior request ), { initialValue: [] as Result[] }, ); ``` - `toSignal(obs$, { initialValue })` — read `results()` in the template; the subscription is cleaned up automatically when the injection context is destroyed. - `toObservable(sig)` — turn a signal into a stream so you can apply operators. It emits on the microtask queue, not synchronously. - `takeUntilDestroyed()` — for the rare hand-rolled subscription, this completes it when the component/service is destroyed. Use it instead of a manual `Subscription` + `ngOnDestroy`. ## effect() pitfalls `effect()` is for side effects, not derivation. Common ways it goes wrong: ```typescript // Pitfall 1 — infinite loop: an effect that writes a signal it also reads. effect(() => this.count.set(this.count() + 1)); // re-runs forever // Pitfall 2 — using an effect to derive state. Use computed() instead. effect(() => this.total.set(this.qty() * this.price())); // double-fires, hidden graph // Fix: total = computed(() => this.qty() * this.price()); // Pitfall 3 — wanting to write without creating a dependency: wrap in untracked(). effect(() => { const id = this.userId(); // tracked dependency untracked(() => this.log.push(id)); // read/write here is NOT a dependency }); ``` - An effect runs once on creation and again whenever any signal it *reads* changes. - Reading a signal inside `untracked(() => …)` does not register it as a dependency. - If you find yourself calling `.set()` from inside an effect to feed the template, you almost certainly want `computed()` or `linkedSignal()`. ## resource() / httpResource() patterns ```typescript // Dependent fetch: the resource re-runs when team() changes. team = signal('eng'); users = httpResource<User[]>(() => `/api/users?team=${this.team()}`); // Mutate then refresh: async addUser(u: User) { await firstValueFrom(this.http.post('/api/users', u)); this.users.reload(); } // Loading / error in the template: // @if (users.isLoading()) { <app-spinner/> } // @else if (users.error()) { <p>Failed</p> } // @else { @for (u of users.value(); track u.id) { … } } ``` - A `resource()` cancels the previous load when its `params` change (it passes an `AbortSignal` to the loader) — use it for `fetch` cancellation without writing RxJS. - `httpResource()` is GET-oriented and reactive. For POST/PUT/DELETE keep `HttpClient` and call `reload()` after the mutation.
-
-
scripts
-
verify.sh 4.2 KB
#!/usr/bin/env bash # # verify.sh — Angular modern-baseline copy-banlist (heuristic lint, NOT a compiler). # # USAGE # bash scripts/verify.sh [PATH ...] # Run from your Angular project root. With no args it scans ./src (falling back # to the current dir). Pass explicit paths to scope it to just-edited files: # bash scripts/verify.sh src/app/users # # WHAT IT DOES # Greps Angular .ts/.html sources for legacy patterns this skill bans, so the # agent can self-correct toward standalone + signals + built-in control flow: # - @NgModule in new code # - legacy structural directives *ngIf / *ngFor / *ngSwitch # - decorator I/O @Input() / @Output() # - @for blocks missing a `track` expression # - constructor-based DI (constructor(private x: X)) where inject() is the rule # Each hit prints file:line. It is a best-effort heuristic, not a parser, so it # may have false positives/negatives — treat it as a hint. # # GUARANTEES # - Read-only: never writes, fixes, installs, or hits the network. # - Exits 0 on an empty/clean target (no sources, or no hits) — no false failure. # - Exits 1 only when at least one banned pattern is found, so it can gate a loop. # - Portable to stock macOS bash 3.2 (no mapfile, no associative arrays). set -u if [ -t 1 ] && command -v tput >/dev/null 2>&1; then RED="$(tput setaf 1)"; GREEN="$(tput setaf 2)"; YELLOW="$(tput setaf 3)"; RESET="$(tput sgr0)" else RED=""; GREEN=""; YELLOW=""; RESET="" fi hits=0 ok() { printf '%s\n' "${GREEN}ok${RESET} $1"; } # --- resolve scan roots ----------------------------------------------------- roots="" if [ "$#" -gt 0 ]; then roots="$*" elif [ -d ./src ]; then roots="./src" else roots="." fi # Collect candidate Angular source files (.ts/.html), skipping vendor/build dirs # and *.spec.ts / *.d.ts. Newline-delimited list; empty if nothing matches. files="$( find $roots \ \( -name node_modules -o -name dist -o -name .angular -o -name .git -o -name coverage \) -prune -o \ -type f \( -name '*.ts' -o -name '*.html' \) \ ! -name '*.spec.ts' ! -name '*.d.ts' -print 2>/dev/null )" if [ -z "$files" ]; then printf '%s\n' "${YELLOW}skip${RESET} no Angular .ts/.html sources under: $roots" exit 0 fi # Grep a pattern across all files, reporting each match as "file:line: text". # $1 = ERE pattern, $2 = human label. Counts hits into the global $hits. scan() { pattern="$1"; label="$2" out="$(printf '%s\n' "$files" | while IFS= read -r f; do [ -n "$f" ] || continue grep -nE "$pattern" "$f" 2>/dev/null | sed "s|^|$f:|" done)" if [ -n "$out" ]; then printf '%s\n' "$out" | while IFS= read -r m; do [ -n "$m" ] && printf '%s\n' "${RED}HIT${RESET} $label $m" done n="$(printf '%s\n' "$out" | grep -c . )" hits=$((hits + n)) else ok "no $label" fi } # @for openers that do NOT contain `track` on the same line — track is required. scan_for_track() { out="$(printf '%s\n' "$files" | while IFS= read -r f; do [ -n "$f" ] || continue grep -nE '@for[[:space:]]*\(' "$f" 2>/dev/null | grep -v 'track' | sed "s|^|$f:|" done)" if [ -n "$out" ]; then printf '%s\n' "$out" | while IFS= read -r m; do [ -n "$m" ] && printf '%s\n' "${RED}HIT${RESET} @for without track (track is required) $m" done n="$(printf '%s\n' "$out" | grep -c . )" hits=$((hits + n)) else ok "no @for without track" fi } printf '=== Angular banlist (%s files under %s) ===\n' "$(printf '%s\n' "$files" | grep -c .)" "$roots" # 1. NgModule in new code. scan '@NgModule' '@NgModule (use standalone components)' # 2. Legacy structural directives. scan '\*ng(If|For|Switch)\b' 'legacy *ngIf/*ngFor/*ngSwitch (use @if/@for/@switch)' # 3. Decorator I/O. scan '@(Input|Output)\(' '@Input()/@Output() decorator (use input()/output())' # 4. @for blocks missing the required `track` expression. scan_for_track # 5. Constructor-based DI. scan 'constructor[[:space:]]*\([^)]*(private|public|protected|readonly)[[:space:]]+[A-Za-z_]+[[:space:]]*:' 'constructor DI (use inject())' printf '\n' if [ "$hits" -gt 0 ]; then printf '%sFAIL%s %d banned-pattern hit(s) — migrate toward standalone + signals.\n' "$RED" "$RESET" "$hits" exit 1 fi printf '%sPASS%s clean — no banned legacy Angular patterns found.\n' "$GREEN" "$RESET" exit 0
-
-
SKILL.md 13.5 KB
--- name: angular description: "Use when building, refactoring, or debugging Angular (v20/21+): standalone components, signals, zoneless change detection, @if/@for/@defer control flow, inject() DI, resource()/httpResource(), RxJS interop, NgRx SignalStore, ng CLI. NOT React (that is react), NOT Next.js (that is nextjs), NOT a TypeScript language question (that is typescript)." tags: [angular, frontend, web, signals, typescript, spa] recommends: [typescript, testing-web, secure-coding] origin: risco --- # Angular — Standalone, Signals, Zoneless (Angular 20/21+) > Build Angular the way it ships in 2026: standalone components, signals as the reactivity model, zoneless change detection, built-in control flow, and `inject()` DI. Treat NgModules, `*ngFor`, and `@Input()` decorators as legacy you only touch to migrate. ## Not this skill **AngularJS (1.x)** is out of scope entirely — this skill is Angular 2+ only and the APIs do not map. Route elsewhere for **React** → `../react/SKILL.md`; **Next.js App Router** → `../nextjs/SKILL.md`; **Vue/Nuxt, Svelte, SolidJS, Astro** → `../vue-nuxt/SKILL.md`, `../svelte/SKILL.md`, `../solid-js/SKILL.md`, `../astro/SKILL.md`; a **pure TypeScript language question** (generics, narrowing, tsconfig) with no Angular dimension → `../typescript/SKILL.md`; a **standalone NestJS API** → `../nestjs/SKILL.md`; a generic Node service → `../nodejs/SKILL.md`; **cross-framework Playwright e2e strategy** → `../testing-web/SKILL.md` / `../e2e-testing/SKILL.md`. Angular Universal SSR and Angular's own `ng test` (Vitest) setup stay here. ## Decide first | Situation | Do this | Why | |-----------|---------|-----| | Greenfield app / new feature | Zoneless + signals + standalone by default. `ng new` (Angular 21) already excludes Zone.js. | The defaults shipped stable in v20-v21; fight them and you write more code that the framework now does for you. | | Brownfield NgModule + decorator app | Migrate incrementally with the schematics in `references/migration.md` (NgModule→standalone, control flow, decorator→signal inputs, Zone.js→zoneless, Karma→Vitest); do not rewrite. Keep Zone.js until you flip it on purpose. | A working app that uses `*ngIf` is not a bug. Churn introduces risk for no user value. | | "View not updating" complaint | Jump to the change-detection section: signal not read in template, `OnPush` without a signal, or stale Zone.js assumption. | Zoneless means a mutation that no signal observes will never repaint — the fix is structural, not a `detectChanges()` call. | ## The modern baseline No NgModules. Bootstrap a standalone root component and configure providers in `app.config.ts`. ```typescript // main.ts import { bootstrapApplication } from '@angular/platform-browser'; import { App } from './app/app'; import { appConfig } from './app/app.config'; bootstrapApplication(App, appConfig); ``` ```typescript // app/app.config.ts import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core'; import { provideRouter } from '@angular/router'; import { provideHttpClient, withFetch } from '@angular/common/http'; import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { providers: [ provideZonelessChangeDetection(), // no Zone.js; CD driven by signals + events provideRouter(routes), provideHttpClient(withFetch()), ], }; ``` - Rule: one `bootstrapApplication` call, providers in `app.config.ts`. Why: NgModule bootstrap (`platformBrowserDynamic().bootstrapModule(AppModule)`) is the legacy path — more files, slower to reason about. - Rule: components are `standalone` by default (the `standalone` flag is implied in v20+; do not write `standalone: true` in new code, and never write `standalone: false`). Why: standalone is the framework default now; the flag is noise. **Bad → Good** ```typescript // Bad — NgModule wiring for a single component @NgModule({ declarations: [UserCard], imports: [CommonModule], exports: [UserCard] }) export class UserCardModule {} // Good — standalone component imports only what it uses @Component({ selector: 'app-user-card', imports: [DatePipe], template: `<p>{{ joined() | date }}</p>`, }) export class UserCard { joined = input.required<Date>(); } ``` ## Signals as the reactivity model `signal()` holds state, `computed()` derives it, `effect()` runs side effects, `linkedSignal()` resets writable state when a source changes. ```typescript import { signal, computed, effect, linkedSignal } from '@angular/core'; const qty = signal(1); const price = signal(9.99); const total = computed(() => qty() * price()); // derived — recomputes lazily const draftQty = linkedSignal(() => qty()); // writable, resets when qty changes effect(() => console.log('total changed:', total())); // side effect ONLY (logging, DOM, sync) ``` - Rule: derive with `computed()`, never with `effect()`. Why: an `effect()` that writes a signal to "compute" a value creates a hidden dependency graph that loops or fires extra times — `computed()` is pull-based and memoized. - Rule: `effect()` is for side effects (logging, `localStorage`, imperative DOM, 3rd-party libs), not for keeping two signals in sync. Why: synced state belongs in `computed()` or `linkedSignal()`. Component I/O is signal-based: `input()`, `input.required()`, `output()`, `model()` for two-way. **Bad → Good** ```typescript // Bad — decorator I/O, mutable, no type-safety on required @Input() userId!: string; @Output() saved = new EventEmitter<User>(); // Good — signal inputs/outputs userId = input.required<string>(); // read as userId() saved = output<User>(); // emit with saved.emit(user) name = model(''); // two-way: [(name)]="..." ``` ## Templates: built-in control flow Use `@if` / `@for` / `@switch` / `@defer`. The legacy `*ngIf` / `*ngFor` / `*ngSwitch` structural directives are deprecated. ```html @if (user(); as u) { <h1>{{ u.name }}</h1> } @else { <app-spinner /> } @for (item of items(); track item.id) { <li>{{ item.label }}</li> } @empty { <li>No items</li> } @defer (on viewport) { <app-heavy-chart [data]="rows()" /> } @placeholder { <div class="skeleton"></div> } ``` - Rule: every `@for` **must** declare `track`. Why: it is required syntax (the template won't compile without it) and it controls DOM reuse — `track item.id` over `track $index` when items have stable identity, or the DOM thrashes on reorder. - Rule: reach for `@defer` to lazy-load heavy sub-trees and enable incremental hydration. Why: it ships less JS up front without manual `loadComponent` plumbing. **Bad → Good** ```html <!-- Bad — legacy structural directive, no tracking --> <li *ngFor="let item of items">{{ item.label }}</li> <!-- Good — built-in control flow with track --> @for (item of items(); track item.id) { <li>{{ item.label }}</li> } ``` ## Data fetching Default to signal-based resources; reach for `HttpClient` + RxJS only when you need streams, cancellation, or operator composition. ```typescript import { httpResource } from '@angular/common/http'; import { resource } from '@angular/core'; // httpResource — declarative GET wired to HttpClient; reactive to its URL signal users = httpResource<User[]>(() => `/api/users?team=${this.team()}`); // template: @if (users.isLoading()) {…} @else { @for (u of users.value(); track u.id) {…} } // users.error() -> error signal; users.reload() -> refetch // resource — any async loader (not just HTTP) profile = resource({ params: () => ({ id: this.userId() }), loader: ({ params }) => fetchProfile(params.id), }); ``` - Rule: `httpResource()`/`resource()` give you `value()`, `isLoading()`, `error()`, `reload()` for free — prefer them over a manual `subscribe` that you have to clean up. Why: less boilerplate, no leak, refetches automatically when its source signals change. - Rule: when you genuinely need a stream (websocket, debounced search, retry/switchMap), keep `HttpClient` + RxJS and bridge to a signal with `toSignal()`. Why: signals are not streams; do not fake backpressure with effects. `references/signals-rxjs.md` has the signals-vs-RxJS decision matrix, `toSignal`/`toObservable` interop recipes, `effect` pitfalls (infinite loops, untracked reads), and `takeUntilDestroyed`. ## DI & services ```typescript @Injectable({ providedIn: 'root' }) export class UserApi { private http = inject(HttpClient); // field initializer — no constructor needed list = () => this.http.get<User[]>('/api/users'); } ``` - Rule: inject with `inject()`, not constructor parameters. Why: `inject()` works in field initializers and composes into plain functions (guards, factories); constructor DI is the legacy ergonomic. - Rule: `providedIn: 'root'` for app-wide singletons. Why: tree-shakable — unused services drop from the bundle. - Rule: HTTP cross-cutting concerns are functional interceptors: `provideHttpClient(withInterceptors([authInterceptor]))`. Why: class interceptors with `HTTP_INTERCEPTORS` are the older multi-provider pattern. ## Routing ```typescript // app.routes.ts export const routes: Routes = [ { path: 'users', loadComponent: () => import('./users/users-list').then(m => m.UsersList) }, { path: 'users/:id', loadComponent: () => import('./users/user-detail').then(m => m.UserDetail), canActivate: [authGuard] }, ]; export const authGuard: CanActivateFn = () => inject(AuthService).isLoggedIn(); ``` Enable route-bound signal inputs with `withComponentInputBinding()` in `provideRouter`, then read route params as signal inputs: ```typescript provideRouter(routes, withComponentInputBinding()); // in UserDetail: id = input.required<string>(); // bound from the :id segment ``` - Rule: lazy-load routes with `loadComponent` (or `loadChildren` with a routes array). Why: smaller initial bundle, no NgModule needed. - Rule: guards/resolvers are functions (`CanActivateFn`, `ResolveFn`) using `inject()`. Why: class-based guards are deprecated. ## State - Local/feature state → a signal service (`@Injectable` holding `signal`/`computed`). Simple, no library. - App-wide state → **NgRx SignalStore** (`signalStore`, `withState`, `withComputed`, `withMethods`, `withProps`) — signals-native, pairs cleanly with `resource()`. ```typescript export const CartStore = signalStore( { providedIn: 'root' }, withState({ items: [] as Item[] }), withComputed(({ items }) => ({ count: computed(() => items().length) })), withMethods((store) => ({ add: (i: Item) => patchState(store, s => ({ items: [...s.items, i] })) })), ); ``` - Note: **Signal Forms** is experimental (prototype since Angular 21.0.0-next.2). For production forms use reactive/typed forms (`FormGroup`/`FormControl` with typed values). Why: do not ship a prototype API to users. ## CLI workflow ```bash ng new my-app # Angular 21: zoneless + standalone + Vitest by default ng generate component user-card # standalone by default; no --standalone flag needed ng generate service user-api ng build # production build ng test # Vitest (default runner in v21; Karma is deprecated) ng update @angular/core @angular/cli # version bumps + automated migrations ``` ## Testing Use Vitest + `TestBed`. Provide zoneless CD in tests and set signal inputs via `componentRef`. ```typescript import { TestBed } from '@angular/core/testing'; import { provideZonelessChangeDetection } from '@angular/core'; it('renders the user name', async () => { TestBed.configureTestingModule({ providers: [provideZonelessChangeDetection()], }); const fixture = TestBed.createComponent(UserCard); fixture.componentRef.setInput('joined', new Date('2026-01-01')); await fixture.whenStable(); // not detectChanges() — let CD settle expect(fixture.nativeElement.textContent).toContain('2026'); }); ``` - Rule: set signal inputs with `fixture.componentRef.setInput('name', value)`, never by poking the instance field. Why: `setInput` flows through the input pipeline and marks the view dirty. - Rule: prefer `await fixture.whenStable()` over manual `detectChanges()` loops under zoneless. Why: it waits for the scheduler to flush instead of forcing a single synchronous pass. ## Anti-patterns | Bad | Why it's wrong | Good | |-----|----------------|------| | `@NgModule` in new code | Standalone is the default; modules add ceremony and slow analysis | Standalone component with an `imports: []` array | | `*ngIf` / `*ngFor` / `*ngSwitch` | Legacy structural directives; deprecated | `@if` / `@for (… ; track id)` / `@switch` | | `@Input()` / `@Output()` decorators | No required-input safety, not signal-reactive | `input()` / `input.required()` / `output()` / `model()` | | `effect(() => this.total.set(a()*b()))` | Effect-to-derive-state loops and double-fires | `total = computed(() => a()*b())` | | `@for` without `track` | Won't compile; if forced, DOM thrashes on reorder | `track item.id` (stable identity) | | `subscribe()` in a component with no teardown | Memory leak; runs after the view is destroyed | `toSignal()` or `takeUntilDestroyed()` | | `ChangeDetectorRef.detectChanges()` to "fix" a stale view | Masks the real cause under zoneless | Read the value through a signal so CD tracks it | | Nested `subscribe()` inside `subscribe()` | Callback pyramid, lost cancellation | `switchMap`/`concatMap`, one subscription | | Constructor DI only (`constructor(private x: X)`) | Legacy ergonomic; can't compose into functions | `private x = inject(X)` | `scripts/verify.sh` is a heuristic copy-banlist lint — it greps your Angular sources for the banned patterns above. It is a hint, not a compiler.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.