Angular Signals skill for Codex

Computed over effects, ready for zoneless. Codex moves Angular components and services onto signals: inputs, outputs and queries use the function APIs, derived state is computed or linkedSignal rather than an effect calling set, BehaviorSubject services become read-only signal stores, and RxJS stays only where debounce or cancellation earn it. It then takes the app through OnPush and zoneless change detection with tests proving views still refresh.

  • Frontend
  • $angular-signals
  • 203 lines
  • 2 files
  • ~2,050 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- angular-signals

Installs to ~/.agents/skills/angular-signals/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $angular-signals, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Writes and migrates Angular 19 to 21 code to signals - signal, computed, linkedSignal, effect and untracked, signal inputs with input() and model(), output(), viewChild and contentChildren queries, toSignal and toObservable interop with RxJS, resource and httpResource, OnPush components, and zoneless change detection with provideZonelessChangeDetection. Use when building Angular components with signals, converting @Input, @Output and @ViewChild code, replacing BehaviorSubject state services, removing zone.js, or fixing views that do not refresh.

Use it when

  • Writing new Angular components or services with signals
  • Migrating decorators and Subject-based state to signals
  • Planning or executing a move to zoneless change detection
  • Views fail to refresh under OnPush

Use something else when

How to check the result

ng build and tests pass with provideZonelessChangeDetection enabled, and no effect in the diff ends by setting another signal that could have been computed.

The mistake it prevents

Updating a signal holding an array by mutating it in place, which returns the same reference so nothing that depends on it refreshes.

~/.agents/skills/angular-signals/SKILL.md

--- name: angular-signals description: Writes and migrates Angular 19 to 21 code to signals - signal, computed, linkedSignal, effect and untracked, signal inputs with input() and model(),… license: MIT ---

Done well means: component state lives in signals, everything derivable is computed, templates read signals directly (no manual markForCheck), RxJS remains only where streams genuinely help (events over time, cancellation, websockets), and the app works under OnPush and zoneless change detection.

Inspect first

  1. Read AGENTS.md, package.json (@angular/core major), and angular.json (polyfills containing zone.js means zone-based change detection).
  2. Read app.config.ts (or main.ts) providers: provideZoneChangeDetection, provideZonelessChangeDetection, provideHttpClient.
  3. Grep the scale of migration: rg -c "@Input\(|@Output\(|@ViewChild|BehaviorSubject" src.

Version notes: effect, linkedSignal, toSignal and signal inputs/queries are stable from v20; zoneless is stable from v20.2 and the default for new apps in v21. resource and httpResource were experimental through v20; check the docs for your version before relying on them in production code.

Component pattern

import { Component, ChangeDetectionStrategy, computed, input, model, output, signal, linkedSignal, inject } from "@angular/core";

@Component({
  selector: "app-product-picker",
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <input [value]="query()" (input)="query.set($any($event.target).value)" placeholder="Filter" />
    @for (p of filtered(); track p.id) {
      <button [class.active]="p.id === selectedId()" (click)="choose(p.id)">{{ p.name }}</button>
    } @empty {
      <p>No products</p>
    }
    <p>{{ filtered().length }} of {{ products().length }}</p>
  `,
})
export class ProductPicker {
  products = input.required<Product[]>();
  pageSize = input(20, { transform: (v: string | number) => Number(v) });
  selectedId = model<string | null>(null);           // two-way: [(selectedId)]="..."
  chosen = output<string>();

  query = signal("");
  filtered = computed(() => {
    const q = this.query().toLowerCase();
    return this.products().filter((p) => p.name.toLowerCase().includes(q)).slice(0, this.pageSize());
  });

  // resets to the first product whenever the list changes, but stays user-editable
  highlighted = linkedSignal(() => this.products()[0]?.id ?? null);

  choose(id: string) {
    this.selectedId.set(id);
    this.chosen.emit(id);
  }
}

Decisions

NeedUseNot
Local mutable statesignalplain field (invisible to zoneless CD)
Derived valuecomputedeffect that calls .set
State that resets when an input changes but can be editedlinkedSignaleffect + set
Parent-to-child valueinput() / input.required()@Input with setter
Two-way bindingmodel()@Input + @Output xChange pair
Event to parentoutput()EventEmitter
Element or child component referenceviewChild(), viewChildren(), contentChild()decorators + ngAfterViewInit
Observable from a service in a templatetoSignal(obs$, { initialValue })manual subscribe + field
Signal into an RxJS pipeline (debounce, switchMap)toObservable(sig)
Async fetch keyed on signalshttpResource / resource (check stability) or toSignal over toObservable(...).pipe(switchMap(...))effect that subscribes
Sync to localStorage, analytics, a non-Angular widgeteffect
DOM measurement after renderafterRenderEffect / afterNextRendereffect
Shared app stateservice with private signal, public asReadonly() + computedBehaviorSubject + getValue()

State service

@Injectable({ providedIn: "root" })
export class CartStore {
  private readonly _lines = signal<CartLine[]>([]);
  readonly lines = this._lines.asReadonly();
  readonly count = computed(() => this._lines().reduce((n, l) => n + l.qty, 0));

  add(sku: string) {
    this._lines.update((lines) => {
      const hit = lines.find((l) => l.sku === sku);
      return hit
        ? lines.map((l) => (l.sku === sku ? { ...l, qty: l.qty + 1 } : l))
        : [...lines, { sku, qty: 1 }];
    });
  }
}

Always produce new arrays and objects in update. Signals compare with Object.is; mutating in place and returning the same reference does not notify anyone.

Effects: rules

  • Effects are for side effects outside Angular’s state graph. If the body ends in someSignal.set(...), it should almost always be a computed or linkedSignal.
  • Read every dependency synchronously at the top. Reads after await or inside callbacks are not tracked.
  • Wrap reads you do not want to track in untracked(() => ...), for example logging the current user inside an effect that should rerun only on route changes.
  • Use onCleanup (the effect callback’s parameter) to cancel timers or subscriptions.
  • Create effects in an injection context (constructor or field initializer). Elsewhere, pass { injector }.

RxJS interop

private http = inject(HttpClient);
query = signal("");
results = toSignal(
  toObservable(this.query).pipe(
    debounceTime(250),
    distinctUntilChanged(),
    switchMap((q) => (q ? this.http.get<Hit[]>(`/api/search`, { params: { q } }) : of([]))),
  ),
  { initialValue: [] as Hit[] },
);
  • toSignal subscribes immediately and unsubscribes on destroy of the injection context. Do not call it inside methods or computed.
  • Without initialValue the signal type includes undefined. Use requireSync: true only for observables that emit synchronously (BehaviorSubject).
  • Errors in the source observable throw when the signal is read. Catch in the pipe.

Going zoneless

// app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [provideZonelessChangeDetection(), provideRouter(routes), provideHttpClient()],
};
- [ ] All components OnPush and passing tests (do this first, with zone still on)
- [ ] No state in plain fields mutated from setTimeout, promises or third-party callbacks;
      convert to signals or call ChangeDetectorRef.markForCheck()
- [ ] Remove zone.js from angular.json polyfills (build and test targets)
- [ ] Remove NgZone.onStable / onMicrotaskEmpty usage; NgZone.run is a no-op now
- [ ] Tests: add provideZonelessChangeDetection() to TestBed; use await fixture.whenStable()
- [ ] SSR: long async work must register with PendingTasks so rendering waits for it

Views refresh in zoneless mode when a signal read by the template changes, on template event listeners, async pipe emissions, markForCheck, and ComponentRef.setInput.

Migration schematics

ng generate @angular/core:signal-input-migration
ng generate @angular/core:output-migration
ng generate @angular/core:signal-queries-migration

Run one at a time on a clean tree, review the diff (inputs that were written to inside the component are skipped or flagged, since signal inputs are read-only), then run tests. Ask before running schematics on a dirty working tree.

Gotchas

  • Template reads need parentheses: {{ count() }}. {{ count }} renders the function.
  • input() values are read-only. If the component modified an @Input, use model() or a linkedSignal seeded from the input.
  • Required inputs throw if read in the constructor; read them in computed, effects, templates or ngOnInit onward.
  • computed must be pure: no HTTP, no .set calls, no DOM.
  • Equality: signal(obj, { equal: (a, b) => a.id === b.id }) when updates produce new objects with the same meaning and you want to skip recomputation.

Verification

ng build
ng test            # Vitest default in v21 projects; Karma in older ones
ng lint            # if angular-eslint is configured
  • Toggle zoneless in a branch and run the e2e suite; any view that fails to refresh points at a plain field mutated outside a template event.
  • Angular DevTools: the signal graph shows computed dependencies; profiler shows OnPush components skipped on unrelated changes.

Output

Report components converted, public API changes for parents ([(x)] bindings, output names), services moved from BehaviorSubject to signals, effects kept and why each is a real side effect, and zoneless readiness status.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Angular Signals"
  short_description: "Computed over effects, ready for zoneless"
  default_prompt: "$angular-signals Convert this component's @Input and @Output decorators to signal inputs and outputs"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem