Claude Skill

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

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies

#typescript

Virus-scanned Reviewed automatically before listing.

Full trust report

Download ericrisco-rsc-harness-skills_angular-953fef5.zip · 12 KB
Part of ericrisco/rsc-harness — 46 skills

Install

skills CLI npx skills add https://github.com/ericrisco/rsc-harness/tree/main/skills/angular
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install ericrisco-rsc-harness@llmmart
Git 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 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

// 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 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

// 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 @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

<!-- 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 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

@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

// 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 (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().
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

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: 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.

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.

No comments yet.

Reviews (0)

No reviews yet.

Related