Un-learn & upgrade
The patterns that were right in 2022 and are wrong now, the error each one produces, and the order to do the migration in.
The un-learn list
Each entry pairs the old habit with the sign you are hitting it, so you can work backwards from a failing build.
- State in a
BehaviorSubject. Symptom: a template that still needs| async, and cleanup code inngOnDestroy. Replace withsignal()— see Signals. *ngIf/*ngForand aCommonModuleimport. Symptom: a template that reads like a directive soup, and atrackByfunction you have to remember. Use@if/@forwithtrack— see Templates & control flow.ngOnChangesto derive from an input. Symptom: the derived value is one frame behind on the first render. Usecomputed()over the input signal — see The component contract.ChangeDetectorRef.markForCheck()/detectChanges(). Symptom: a nudge call that only exists because a value outside the signal graph drives the template. Make it a signal — see Zoneless change detection.- Constructor injection with
private readonlyparameters. Not wrong, but it cannot be used in guards, resolvers or factories. Useinject()— see Dependency injection. @Injectable({ providedIn: 'root' })on everything. Fine, but@Service()is the v22 one-liner, and narrower providers are usually what you actually wanted.subscribe()in a component to fill a field. Symptom: aSubscriptionfield and anngOnDestroy, or a leak. Useresource()/httpResource()/toSignal()— see HTTP & async data.onload="this.media='all'"style workarounds for CSS. Symptom: a stylesheet that loads but does not apply — which, under a strict CSP that blocks inline scripts, is exactly what happens. Turn off Angular's critical-CSS inlining instead; this site does.- A
SharedModulethat exports everything. Symptom: deleting one import breaks three unrelated components. Import per component — see Standalone components. setTimeoutinside a component to wait for the DOM. UseafterNextRender()orafterRenderEffect().
The errors you will actually see
| Error | What it means | Where to look |
|---|---|---|
NG8113: 'X' is not used within the template |
An import was added but the template does not use it — common after the standalone migration | Delete the import |
'app-thing' is not a known element |
The component using it does not import it (there is no ambient module any more) | Add it to imports |
inject() must be called from an injection context |
inject() inside a callback, a setTimeout, or after an await |
Move it to a field, or use runInInjectionContext |
NG0100: Expression has changed after it was checked |
State was written during change detection | Derive with computed instead of writing state |
NG0500: hydration mismatch |
The browser's first render differs from the prerendered HTML | Remove Date.now()/localStorage from render paths |
Cannot match any routes |
A routerLink or navigate path does not exist |
Check the route table, including the ** entry |
Type 'Signal<T>' is not assignable to ... |
A signal was passed where its value was expected | Read it: count() |
Upgrading
The mechanics have not changed, and the discipline around them matters more than the commands:
ng update # what is outdated, and what the CLI recommends
ng update @angular/core @angular/cli
ng update @angular/core@22 @angular/cli@22 # pin the next major explicitly- Never
--forcepast a peer-dependency error to make an upgrade "work" — it usually means a third-party library has not caught up, and you will find out at runtime. - Read the update guide for each major. Angular's
update.angular.iolists the breaking changes between your version and the target, and the migrations that automate them (ng updateruns schematics such as the standalone conversion and the control-flow rewrite). - Upgrade one major at a time, on a branch, with the tests green between steps. Angular supports skipping majors, but a bisectable history is worth more than a single afternoon.
- Convert to standalone before converting to signals. The standalone schematic is mechanical and mostly safe; the signals conversion changes how your components behave, and mixing the two in one branch makes both harder to review.
A pragmatic order for a real application:
ng updateto the target version, fix compile errors, ship it. No behaviour change.ng generate @angular/core:standalone, prune the modules, ship it.- Convert state to signals component by component, starting with the components that have the most manual change-detection nudges.
- Convert templates to the new control flow (
ng generate @angular/core:control-flow). - Remove
zone.jslast, onceprovideCheckNoChangesConfig({ exhaustive: true })is quiet in development.
CLI cheatsheet
ng new app --style=tailwind --zoneless # new project, current defaults
ng generate component pages/topic --skip-tests # scaffold
ng generate @angular/core:standalone # Module → standalone migration
ng generate @angular/core:control-flow # *ngIf → @if migration
ng generate @angular/core:signals # introduce signal inputs
ng build # production bundle
ng serve # dev server (esbuild + Vite, HMR)
ng test # unit tests
ng update # upgrade guidance
ng add @angular/ssr # add prerendering / SSREvery ng generate takes --dry-run; use it on the migrations before you let them write.
The failure mode to watch for during a migration is not a build error, it is a silently stale view: a value updated outside the signal graph still renders in development because a click handler happened to trigger a check, and stops rendering once the value changes from a timer or a socket. That is the pattern in item 4 and 8 above, and it is why the exhaustive check configuration — not the test suite — is what tells you the migration is finished.
What to take away
Almost nothing you know is wasted: components, DI, routing and forms all still mean what they meant. The migration is mostly deleting bookkeeping that the framework no longer needs — and the bookkeeping is exactly what the Old → new cheatsheet lists, side by side.