Modern Angular skill for Claude Code

Signals, control flow and zoneless, version-aware. Claude checks the installed Angular major first, then writes code that fits it: signal inputs and computed state instead of decorators and manual syncing, built-in control flow with real track keys, deferred heavy components, inject() and functional guards. It migrates NgModule and zone.js apps one schematic at a time and proves zoneless and hydration work by exercising the app, not just compiling it.

  • nameangular-modern
  • size256 lines · ≈2,750 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Writes and migrates Angular 17+ code (through v22) the modern way — standalone components, signals with computed, linkedSignal and effect, signal inputs/outputs/model, built-in @if/@for/@switch control flow, @defer blocks, inject(), resource and httpResource, Signal Forms, zoneless change detection, and SSR with incremental hydration. Use when generating Angular components or services, converting NgModules or *ngIf/*ngFor templates, replacing RxJS state with signals, upgrading Angular majors with ng update, or debugging change detection and hydration mismatches.

Try asking

  • Convert this NgModule feature to standalone components with signals and the new control flow
  • Our view stops updating after removing zone.js — find what still depends on it
  • Upgrade this Angular 18 app to 22 and tell me what the OnPush default breaks

Use it when

  • Generating new Angular components, services, guards or interceptors
  • Migrating *ngIf/*ngFor, NgModules or @Input decorators to modern APIs
  • Going zoneless or adopting OnPush across an existing app
  • Adding SSR, incremental hydration or @defer blocks

Skip it when

How to check the result

ng build shows deferred components as lazy chunks with no budget warnings, tests pass, and after removing zone.js every timer, socket and widget flow still updates the view.

The common mistake it prevents

Mutating an array inside a signal with push() instead of update() with a new array, so nothing re-renders once the app is OnPush or zoneless.

~/.claude/skills/angular-modern/SKILL.md

--- name: angular-modern description: Writes and migrates Angular 17+ code (through v22) the modern way — standalone components, signals with computed, linkedSignal and effect,… license: MIT ---

Modern Angular code is standalone, signal-driven, OnPush-friendly and zoneless-ready: state lives in signals, templates use built-in control flow, dependencies come from inject(), heavy UI is deferred, and nothing depends on zone.js patching to update the screen.

Step 0 — establish the version before writing code

npx ng version                         # Angular CLI + framework packages
grep -n "zone.js\|provideZone" package.json src/main.ts src/app/app.config.ts angular.json
grep -rn "NgModule" src --include=*.ts | wc -l      # how much legacy module code exists
grep -rn "\*ngIf\|\*ngFor" src --include=*.html | wc -l
VersionWhat changes for generated code
17Built-in control flow (@if, @for, @switch), @defer, standalone default in CLI, signal inputs (17.1)
18model() inputs, zoneless experimental, @let (18.1), stable control flow and @defer
19standalone: true is the default (omit it), linkedSignal, resource experimental, incremental hydration preview
20Signal APIs stable (effect, linkedSignal, toSignal), zoneless developer preview then stable (20.2), httpResource
21New projects zoneless by default, Vitest default test runner, Signal Forms experimental
22OnPush is the default change detection (old default renamed Eager), Signal Forms and resource/httpResource stable, TypeScript 6, Node 20 dropped

Match the project’s version. Never emit standalone: true on 19+ (it is noise), never emit signal-only APIs on 16 or older, and never assume zoneless on an app that still loads zone.js.

Component template

import { ChangeDetectionStrategy, Component, computed, inject, input, output, signal } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { CartService } from './cart.service';

@Component({
  selector: 'app-cart-line',
  imports: [CurrencyPipe],
  changeDetection: ChangeDetectionStrategy.OnPush,   // explicit on < 22; default on 22+
  template: `
    @if (item(); as it) {
      <span>{{ it.name }}</span>
      <input type="number" min="1" [value]="qty()" (input)="qty.set(+$any($event.target).value)" />
      <strong>{{ total() | currency }}</strong>
      <button type="button" (click)="removed.emit(it.id)">Remove</button>
    }
  `,
})
export class CartLine {
  item = input.required<CartItem>();
  removed = output<string>();
  qty = signal(1);
  total = computed(() => this.item().price * this.qty());
  private cart = inject(CartService);
}

Rules:

  • input() / input.required() / output() / model() instead of @Input/@Output decorators in new code. Inputs are read-only signals; never .set() an input.
  • Use model() only for genuine two-way binding ([(value)]); otherwise input + output.
  • computed() for anything derived. Never mirror an input into a signal via effect.
  • linkedSignal() when local state must reset whenever a source changes (selected item that resets when the list changes).
  • effect() only for side effects that leave Angular: localStorage, analytics, imperative third-party widgets. Never write to other signals inside an effect to “sync” state.

Built-in control flow

@for (user of users(); track user.id) {
  <app-user-row [user]="user" />
} @empty {
  <p>No users match this filter.</p>
}

@switch (status()) {
  @case ('loading') { <app-spinner /> }
  @case ('error') { <app-error [retry]="reload" /> }
  @default { <app-table [rows]="rows()" /> }
}

@let fullName = user().first + ' ' + user().last;
  • track is mandatory. Track a stable id, never $index for lists that reorder or insert (it destroys and recreates DOM and loses input focus).
  • Migrate old templates with the schematic, then review the diff: ng generate @angular/core:control-flow.

Deferrable views

@defer (on viewport; prefetch on idle) {
  <app-revenue-chart [data]="series()" />
} @placeholder (minimum 300ms) {
  <div class="chart-skeleton" style="height: 320px"></div>
} @loading (after 150ms; minimum 400ms) {
  <app-spinner />
} @error {
  <p>Chart failed to load.</p>
}
  • Components inside @defer must be standalone and not referenced elsewhere eagerly, or they stay in the main bundle. Verify in the build output chunk list.
  • Give the placeholder the final height to avoid layout shift.
  • Triggers: on viewport, on interaction, on hover, on idle, on timer(2s), when condition(). Default to on viewport for below-the-fold, on interaction for dialogs and editors.

Dependency injection

  • inject() in field initializers over constructor parameters. It works in functions called from an injection context (guards, resolvers, interceptors as functions).
  • On 22, the @Service() decorator is the new shorthand for root singletons; on older versions use @Injectable({ providedIn: 'root' }). Check the installed version before emitting either.
  • Functional guards and interceptors:
export const authGuard: CanActivateFn = () =>
  inject(AuthStore).isLoggedIn() ? true : inject(Router).createUrlTree(['/login']);

export const authInterceptor: HttpInterceptorFn = (req, next) =>
  next(req.clone({ setHeaders: { Authorization: `Bearer ${inject(AuthStore).token()}` } }));

Async data with signals

NeedUse
GET that re-runs when signals changehttpResource(() => \/api/users/${id()}`)`
Arbitrary async loader (fetch, SDK)resource({ params: () => ({ id: id() }), loader: ({ params, abortSignal }) => ... })
Existing Observable streamrxResource or toSignal(obs$, { initialValue })
Mutations (POST/PUT/DELETE)HttpClient call in a method, then resource.reload() or update local signal
Complex event streams (debounce, websockets, combineLatest)Keep RxJS; convert at the edge with toSignal
userId = input.required<string>();
user = httpResource<User>(() => `/api/users/${this.userId()}`);
// template: @if (user.isLoading()) {...} @else if (user.error()) {...} @else { {{ user.value()?.name }} }

The resource option was named request before it became params (renamed during v20). Check the installed version’s signature. Resources are for reads; never trigger a write by changing a resource’s params.

Forms

  • New forms on 21+: Signal Forms (form, FormField, validators from @angular/forms/signals); stable on 22, experimental on 21 — on 21, ask before adopting it in production code.
model = signal({ email: '', password: '' });
loginForm = form(this.model, (p) => { required(p.email); email(p.email); required(p.password); });
// <input type="email" [formField]="loginForm.email" />
// loginForm.email().invalid(), .touched(), .errors()
  • Otherwise typed Reactive Forms (FormBuilder.nonNullable). Avoid template-driven forms for anything with cross-field validation.

Zoneless and change detection

Zoneless means Angular re-renders only on signal changes, template events, async pipe emissions, and markForCheck(). Code that mutated a plain field inside setTimeout, a promise, or a third-party callback stops updating the view.

Migration checklist:

- [ ] Component state that changes over time is in signals (or emitted through async pipe)
- [ ] No reliance on NgZone.onStable / onMicrotaskEmpty; use afterNextRender / afterRenderEffect
- [ ] Third-party callbacks write to signals, not plain fields
- [ ] provideZonelessChangeDetection() in app.config (implicit on 21+ new apps)
- [ ] Remove zone.js from polyfills in angular.json and from package.json
- [ ] Tests: remove fakeAsync/zone-dependent helpers or provide zoneless in TestBed

On 22, components without an explicit strategy are OnPush; ng update adds ChangeDetectionStrategy.Eager to existing ones to preserve behavior. Remove Eager component by component as each is converted to signals.

SSR and hydration

ng add @angular/ssr
  • provideClientHydration(withEventReplay(), withIncrementalHydration()) in app.config.
  • Incremental hydration: @defer (hydrate on viewport) keeps server HTML and hydrates the block lazily.
  • Browser-only APIs (window, localStorage, IntersectionObserver) go in afterNextRender() or behind isPlatformBrowser(inject(PLATFORM_ID)).
  • Hydration mismatch NG0500-series errors: usually invalid HTML nesting (<p><div>), DOM manipulated outside Angular, or time/random values rendered differently on server and client. Fix the cause; ngSkipHydration is a last resort per component.
  • Route-level render mode lives in app.routes.server.ts (RenderMode.Prerender, RenderMode.Server, RenderMode.Client).

Upgrades

ng update                                    # shows what can be updated
ng update @angular/core@20 @angular/cli@20   # one major at a time
  • Step through majors one at a time; run tests and build after each.
  • Useful schematics: @angular/core:standalone, @angular/core:control-flow, @angular/core:signal-input-migration, @angular/core:output-migration, @angular/core:inject, @angular/core:route-lazy-loading. Run each separately and commit between them so the diffs stay reviewable.
  • Read https://angular.dev/update-guide for the version pair before upgrading.

Gotchas

  • Calling a signal is a read: {{ count }} renders the function, {{ count() }} the value.
  • Mutating an object inside a signal (items().push(x)) does not notify. Use items.update(list => [...list, x]).
  • computed that reads no signal never recomputes; class fields are not reactive.
  • effect runs at least once and asynchronously; do not use it for initialization order.
  • @for over a Map/Set: convert to an array in a computed first.
  • toSignal in a component subscribes for the component’s lifetime; calling it in a method creates a leak-prone subscription each call.
  • Standalone component used but not in imports → “is not a known element” error. Add it to the consuming component’s imports, not a shared module.

Verification

  1. ng build — no warnings about CommonJS deps or budget overruns; deferred components appear as separate lazy chunks.
  2. ng test (Vitest on 21+, Karma on older) and ng lint pass.
  3. For zoneless: remove zone.js, run the app, and click through every flow that uses timers, websockets, or third-party widgets — the view must update without interaction.
  4. For SSR: ng build && node dist/<app>/server/server.mjs, load pages with JS disabled (content present) and enabled (no NG05xx hydration warnings in the console).

Report format

**Angular version:** 20.3 (zoneless: yes, SSR: yes)
**Changed:** UserList → signals + @for track id; ChartPanel deferred on viewport
**Migrations run:** control-flow, signal-input-migration (reviewed, 14 files)
**Change detection:** OnPush everywhere; no Eager components left in /features/billing
**Verified:** ng build chunks (chart lazy, 182 kB), ng test green, no hydration warnings

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc