Framework · Angular 22 · Standalone · Signals · Zoneless
Angular
Standalone components, signals and typed forms — modern Angular.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou write Angular for a signal-first, zoneless, standalone codebase. "Good" means: no NgModules, no any, no leaked subscriptions, OnPush everywhere, signals for state, built-in control flow, and code that typechecks under strict and passes lint on the first run.
Stack
- Angular 22.0.x (
@angular/core,@angular/common,@angular/router,@angular/forms,@angular/platform-browser). Standalone + signals + zoneless is the baseline. - TypeScript 6.0.x (
>=6.0.0 <6.1.0). 5.9 and below are unsupported by v22.strict: trueplusnoUncheckedIndexedAccess,noImplicitOverride,exactOptionalPropertyTypes. - Node.js 24 LTS (supported range
^22.22.3 || ^24.15.0 || ^26.0.0). Node 20 is EOL — do not target it. - RxJS 7.8.x (v22 peer range
^6.5.3 || ^7.4.0; ship the latest 7.8.x — v22 does not support RxJS 8 yet). Used for streams/events only, never for component state. - Angular CLI 22 with the
@angular/build(esbuild/Vite) application builder. No@angular-devkit/build-angularwebpack builder for new apps. - zone.js is not a dependency in new apps — zoneless change detection is stable and the default. Only add
zone.js(0.15.x) if you must interop with a legacy zone-dependent lib. - Signal Forms (
@angular/forms/signals) — stable in v22 — for new forms. Typed non-nullable Reactive Forms (@angular/forms) remain valid for existing/complex flows. - Vitest via
@angular/build:unit-testis the default test runner. Karma/Jasmine are removed for new projects. Playwright for e2e. - Lint/format: angular-eslint + typescript-eslint (flat config,
strict-type-checked) + Prettier.
Project conventions
- Standalone only. No
NgModule, nodeclarations, noSharedModule. Composition is via a component/directive/pipeimportsarray. - Folder layout: feature-first.
src/app/<feature>/holds that feature's components, services, routes (<feature>.routes.ts), and models. Cross-cutting singletons insrc/app/core/, reusable presentational pieces insrc/app/shared/. - File naming (v20+ style guide): drop the type suffix.
user-profile.tsexporting classUserProfile,auth.tsexportingAuthservice,<feature>.routes.tsfor route arrays. Templates/styles as sibling.html/.cssfiles; inline only for <15-line templates. - Imports: always use the
@angular/*public entry points. Configure a@app/*path alias intsconfigfor app-internal absolute imports; no deep../../../chains. - Bootstrap:
bootstrapApplication(App, appConfig)inmain.ts; providers live inapp.config.tsviaApplicationConfig. NoplatformBrowserDynamic().bootstrapModule. - Run
npx prettier --writeandng lintbefore every commit.ng lintmust be clean — do not disable rules inline to pass.
Components and templates
Every component is standalone with OnPush. v22 makes OnPush the default, so omit
changeDetection; never setChangeDetectionStrategy.Default.Signal-based I/O only. Use
input()/input.required<T>(),output(), andmodel()— never the@Input()/@Output()decorators.readonly userId = input.required<string>(); readonly saved = output<User>(); readonly value = model<string>(''); // two-way: [(value)]Signal queries:
viewChild(),viewChildren(),contentChild(),contentChildren()— not the@ViewChild/@ContentChilddecorators.Built-in control flow only. Use
@if/@else,@for(with mandatorytrack),@switch. Never*ngIf,*ngFor,*ngSwitch, orNgIf/NgForOfimports.@for (item of items(); track item.id) { <app-row [item]="item" /> } @empty { <p>No items.</p> }trackmust be a stable identity (id), never$indexfor keyed lists.Use
@letfor template-local derived values and@defer(on idle/on viewport/on interaction) for lazy, hydration-friendly rendering.Host bindings go in the
hostobject of the decorator, not@HostBinding/@HostListener.Prefer
[class.x]/[style.x]bindings overngClass/ngStyle. Import only the pipes you actually use (AsyncPipe,DatePipe, etc.), notCommonModulewholesale.Bind observables with the
asyncpipe or convert withtoSignal— never call.subscribe()in a backing field to push into a mutable property.
State and reactivity (Signals)
- Component and shared state is signals:
signal()for writable,computed()for derived,linkedSignal()for derived-but-locally-writable state (e.g. a selection that resets when its source list changes). - Update immutably:
count.update(c => c + 1)/state.set(next). Never mutate the object inside a signal in place. computedfor derivation;effectonly for side effects (DOM you can't template, logging,localStorage, syncing to a non-Angular lib). Never use aneffectto copy one signal into another — that's acomputed/linkedSignal. Effects run in an injection context and auto-clean on destroy.- Use
untracked()to read a signal inside acomputed/effectwithout creating a dependency. - Keep signals
readonlywhen exposed; expose writes via methods or.asReadonly(). A store service holds#state = signal(...)privately and exposescomputedselectors.
Data fetching and HTTP
Provide the client once:
provideHttpClient(withFetch(), withInterceptors([authInterceptor])).withFetch()is required for the modern fetch backend and SSR streaming.Interceptors are functions (
HttpInterceptorFn), registered viawithInterceptors([...]). No class-basedHTTP_INTERCEPTORSmulti-providers.Reads →
httpResource(stable in v22) for declarative, signal-driven GETs that re-fetch when inputs change; it exposesvalue(),status(),isLoading(),error()as signals.readonly userId = input.required<string>(); readonly user = httpResource<User>(() => `/api/users/${this.userId()}`); // template: @if (user.isLoading()) { … } @else { {{ user.value()?.name }} }Writes / commands → typed
HttpClient:inject(HttpClient).post<Order>('/api/orders', body). Always supply the response generic; never leave itObject.Use
resource()/rxResource()for non-HTTP async or when you need an RxJS loader. For search-as-you-type, thedebounced()primitive (@angular/core) keeps debouncing inside the signal graph — but it is experimental (developer preview) in v22 and may change, so adopt it only behind that caveat. The stable fallback isdebounceTimeon atoObservable(query)stream feedingrxResource.Type every payload with an
interface/type. Noanyon responses — decode/narrow unknown external data at the boundary.
RxJS interop
- Signals for state, RxJS for streams (websockets, DOM events, complex time-based orchestration). Do not model plain component state as a
BehaviorSubject. - Bridge with
toSignal(obs$, { initialValue })andtoObservable(sig).toSignalunsubscribes automatically on destroy. - If you must
.subscribe()manually, pipetakeUntilDestroyed()(call it in an injection context, or pass aDestroyRef). A bare.subscribe()with no teardown is a leak and is not allowed. - Prefer declarative composition (
switchMap,combineLatest) over nested subscribes. One subscription per stream, torn down deterministically.
Forms
New forms → Signal Forms (
@angular/forms/signals). The model is a writable signal with non-nullable initial values ('',0,[]— nevernull). Bind inputs with the[formField]directive.import { form, FormField, submit, required, email } from '@angular/forms/signals'; protected readonly model = signal({ email: '', password: '' }); protected readonly loginForm = form(this.model, (path) => { required(path.email, { message: 'Email required' }); email(path.email); required(path.password); }); protected send() { submit(this.loginForm, async () => this.auth.login(this.model())); }Template:
<input type="email" [formField]="loginForm.email" />; read state vialoginForm.email().errors(),().touched(),().valid(), andloginForm().invalid(). Usedisabled/readonly/hidden/applyWhen/applyEachfor conditional logic andvalidate/validateAsyncfor custom rules.Existing/complex forms → typed non-nullable Reactive Forms. Build with
inject(NonNullableFormBuilder)so controls areFormControl<T>(neverT | null). Nevernew FormBuilder()untyped, never nullable controls, never[ngModel]template-driven forms for anything non-trivial.Validation is typed and declarative; surface errors from the control/field state, not by reaching into the DOM.
Routing
provideRouter(routes, withComponentInputBinding(), withViewTransitions())inapp.config.ts. Route params/query/data bind straight to componentinput()s viawithComponentInputBinding().- Lazy-load with
loadComponent(single component) andloadChildren: () => import('./x.routes')(feature route arrays). No eagerly imported feature trees. - Guards and resolvers are functions:
CanActivateFn,CanMatchFn,ResolveFn, usinginject()inside. No class-basedCanActivateguards. - Prefer
CanMatchoverCanActivatefor auth so unauthorized routes don't even match (and their lazy bundles never load).
Dependency injection
inject()everywhere — in fields, functions, guards, interceptors, and factories. Constructor-parameter injection only when a base class forces it.- Tree-shakable singletons via
@Injectable({ providedIn: 'root' }). Feature/route-scoped services via the route'sproviders. - Config/values via typed
InjectionToken<T>with a factory; never inject by string. Useinject(TOKEN, { optional: true })for optional deps.
Testing
- Vitest (
ng test, builder@angular/build:unit-test, jsdom env). Co-locate*.spec.tsnext to source. TestBed.configureTestingModule({ imports: [Component] })for standalone components — components go inimports, notdeclarations. Override providers with{ provide, useValue }.- Test behavior through signals and the rendered DOM: set inputs via
fixture.componentRef.setInput('userId', 'x'), callfixture.detectChanges(), assert oncomponentInstance.someSignal()and on DOM queried through CDKComponentHarnessrather than brittle CSS selectors. - Mock HTTP with
provideHttpClientTesting()+HttpTestingController; assert requests andflush()typed responses. ForhttpResource, drive the signal input and flush the expected URL. - Test signal logic (
computed/linkedSignal) as plain functions where possible — they're synchronous and need noTestBed. Use fake timers for debounced/async flows. e2e critical paths in Playwright. - No test may pass by disabling change detection or asserting on
any. Cover: guard allow/deny, form validation states, error branches of resources.
Security
- Never bypass sanitization. Avoid
bypassSecurityTrustHtml/...TrustResourceUrl; if unavoidable, sanitize the source first and document why. Angular's default interpolation/binding sanitization must remain intact — noinnerHTMLwith untrusted data. - Set a strict Content-Security-Policy. Angular supports CSP nonces via the
CSP_NONCEtoken; do not enableunsafe-inline. - Enable Angular's built-in XSRF:
provideHttpClient(withXsrfConfiguration(...))and ensure the backend sets the cookie/verifies the header. - Never interpolate user input into a template, URL, or
[href]/[src]without validation;javascript:URLs are stripped by Angular — don't re-introduce them via a bypass call. - Keep secrets/tokens out of the client bundle and out of
localStoragewhere feasible (prefer HttpOnly cookies). Environment files are public — treat anything shipped to the browser as exposed. - Run
npm audit, keep Angular current, pin dependency ranges, and review transitive updates.
Do
- Bootstrap standalone with
bootstrapApplication+ApplicationConfig; keep providers inapp.config.ts. - Model all state as
signal/computed/linkedSignal; expose read-only signals from services. - Use
@if/@for(+track)/@switch,@let, and@deferin templates. - Use
input()/output()/model()/viewChild()signal APIs andinject()for DI. - Fetch reads with
httpResource/resource; mutate with typedHttpClient; write interceptors and guards as functions. - Lazy-load routes with
loadComponent/loadChildrenand bind params withwithComponentInputBinding(). - Keep OnPush (the v22 default), non-nullable typed forms, and clean teardown (
toSignal/asyncpipe/takeUntilDestroyed).
Avoid
NgModule,declarations,SharedModule, blanketCommonModuleimports → standalone components with explicitimports.*ngIf/*ngFor/*ngSwitch/ngClass/ngStyle→@if/@for track/@switchand[class.x]/[style.x].@Input()/@Output()/@ViewChild()decorators →input()/output()/model()/viewChild().ChangeDetectionStrategy.Default→ rely on the OnPush default; never opt out..subscribe()withouttakeUntilDestroyed(), andBehaviorSubject-as-state →toSignal/signals.effect()to sync signals →computed/linkedSignal.- Untyped
HttpClientcalls, class-based interceptors/guards, nullableFormBuildercontrols, template-drivenngModelforms → typed generics, functionalHttpInterceptorFn/CanMatchFn,NonNullableFormBuilderor Signal Forms. any(andas anycasts) → real interfaces, generics,unknown+ narrowing.platformBrowserDynamic().bootstrapModule, the webpackbuild-angularbuilder, zone.js in new apps →bootstrapApplication,@angular/build, zoneless.
When you code
- Make small, focused diffs. Touch one feature/concern per change; don't opportunistically rewrite unrelated files or "modernize" code you weren't asked to.
- After any change, run
ng lint,tsc --noEmit(orng build), andng testfor the affected area, and report the results. Do not hand back code that fails typecheck or lint. - When adding a component/service, wire it end to end (providers, route, imports) — no dangling, unreferenced files.
- Match the existing pattern in the file; if the repo still uses an older idiom (e.g. decorators or
*ngFor), follow the surrounding style unless the task is explicitly to migrate. - Ask before: introducing a new dependency, changing global providers/
app.config.ts, switching the forms or state strategy, altering the build/test toolchain, or migrating away from an established pattern. Prefer the framework primitive over adding a library. - If a requested API is deprecated or removed in v22, say so and implement the current equivalent instead of the outdated one.
Drop it in your repo
Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on Angular 22 · Standalone · Signals · Zoneless.