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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- angular-signals Installs to ~/.agents/skills/angular-signals/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install angular-signals The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project angular-signals Installs to .agents/skills/angular-signals/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/angular-signals.zipunzip -o angular-signals.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) angular-signals PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- The work is converting NgModules to standalone components or new control flow: use angular-standalone-migration
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.
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
- Read
AGENTS.md,package.json(@angular/coremajor), andangular.json(polyfillscontainingzone.jsmeans zone-based change detection). - Read
app.config.ts(ormain.ts) providers:provideZoneChangeDetection,provideZonelessChangeDetection,provideHttpClient. - 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
| Need | Use | Not |
|---|---|---|
| Local mutable state | signal | plain field (invisible to zoneless CD) |
| Derived value | computed | effect that calls .set |
| State that resets when an input changes but can be edited | linkedSignal | effect + set |
| Parent-to-child value | input() / input.required() | @Input with setter |
| Two-way binding | model() | @Input + @Output xChange pair |
| Event to parent | output() | EventEmitter |
| Element or child component reference | viewChild(), viewChildren(), contentChild() | decorators + ngAfterViewInit |
| Observable from a service in a template | toSignal(obs$, { initialValue }) | manual subscribe + field |
| Signal into an RxJS pipeline (debounce, switchMap) | toObservable(sig) | |
| Async fetch keyed on signals | httpResource / resource (check stability) or toSignal over toObservable(...).pipe(switchMap(...)) | effect that subscribes |
| Sync to localStorage, analytics, a non-Angular widget | effect | |
| DOM measurement after render | afterRenderEffect / afterNextRender | effect |
| Shared app state | service with private signal, public asReadonly() + computed | BehaviorSubject + 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 acomputedorlinkedSignal. - Read every dependency synchronously at the top. Reads after
awaitor 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[] },
);
toSignalsubscribes immediately and unsubscribes on destroy of the injection context. Do not call it inside methods orcomputed.- Without
initialValuethe signal type includesundefined. UserequireSync: trueonly 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, usemodel()or alinkedSignalseeded from the input.- Required inputs throw if read in the constructor; read them in
computed, effects, templates orngOnInitonward. computedmust be pure: no HTTP, no.setcalls, 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.yaml
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.