
Angular 12–15 ➜ 20: Signals, NgRx→SignalStore, RxJS 7 fixes, and change detection that won’t break prod
A field-tested path to upgrade Angular 12–15 apps to 20 with Signals, SignalStore, RxJS refactors, and safer change detection—rooted in real enterprise codebases.
Adapters first. Replace the engine while the plane flies—then flip the flag when metrics are green.Back to all posts
I’ve upgraded enough enterprise Angular apps (a global entertainment company employee tracking, Charter ad analytics, United’s airport kiosks) to know where teams get burned: state management edges, RxJS semantics, and change detection assumptions. This is the focused playbook I use when I’m hired as an Angular consultant to take 12–15 codebases to Angular 20+ without production breakage.
As companies set 2025 roadmaps, you don’t need a rewrite—you need an adapter strategy that lets Signals and SignalStore coexist with NgRx and RxJS while you measure UX. Below is how I do it with Nx, PrimeNG, Firebase (when relevant), and CI guardrails.
The scene: a jittery dashboard and a hiring deadline
I start with measurement: Angular DevTools flame charts and GA4 to baseline TTI, CLS, and error rates. Then we adapt—not rewrite—state so we can turn new slices on per route/tenant.
What I walked into
This looked a lot like the Charter ad analytics platform I stabilized: real-time telemetry, data virtualization, SSE/WebSockets, and pressure on Core Web Vitals. The team wanted Angular 20 Signals but feared regressions.
Dashboard charts stuttering on WebSocket bursts
NgRx selectors scattered; Subjects misused as event buses
ChangeDetectorRef sprinkled everywhere to fight async jitter
Goal for week one
If you need to hire an Angular developer to steady the ship, this is the path that lets directors sleep at night.
Keep prod stable
Introduce Signals incrementally
Prove we can roll back in one flag flip
Why Angular 12 apps break during Signals migration
This is the blueprint we’ll follow: adapters first, then SignalStore slices, and only then consider zoneless.
The three failure modes
Signals reactivity is push-synchronous and identity-stable. If your NgRx selectors emit new objects each tick, components may over-render. RxJS 7 changed shareReplay and removed toPromise. And zone.js-driven change detection hides async timing bugs that surface when you add Signals.
State identity churn
RxJS semantics drift
Change detection assumptions
What to fix first
If you tame these, the rest is incremental.
Normalize selectors and memoization
Stabilize streams with shareReplay config
Eliminate manual detectChanges where possible
How an Angular Consultant Approaches Signals Migration
Adapters buy you time and safety; SignalStore earns simplicity over time.
Step 1: Upgrade framework safely with Nx
Nx keeps changes small and reviewable. Use feature flags (Firebase Remote Config or simple env flags) to gate new state slices.
npx nx migrate @angular/core@20 @angular/cli@20
Fix peer deps; run migrations; commit each stage
Step 2: Bridge NgRx + RxJS to Signals
This lets container components adopt Signals without destabilizing business logic.
Use toSignal with stable initialValue
Leave reducers/effects intact initially
Step 3: Introduce SignalStore per slice
Start with low-risk features (filters, pagination) and expand.
withState, withComputed, withMethods
Keep action/event boundaries typed
Bridging NgRx selectors and RxJS streams to Signals
// apps/dashboard/src/app/containers/analytics.container.ts
import { Component, computed, inject, signal } from '@angular/core';
import { Store } from '@ngrx/store';
import { toSignal } from '@angular/core/rxjs-interop';
import { selectChartData, selectLoading } from '../state';
@Component({
selector: 'app-analytics',
template: `
<p-progressSpinner *ngIf="loading()" />
<app-chart [series]="series()"></app-chart>
`,
})
export class AnalyticsContainer {
private store = inject(Store);
// Provide stable initial values to keep SSR/tests deterministic
readonly series = toSignal(this.store.select(selectChartData), { initialValue: [] });
readonly loading = toSignal(this.store.select(selectLoading), { initialValue: true });
// Derived graph with computed() — identity stable, fewer checks
readonly top5 = computed(() => this.series().slice(0, 5));
}Best practice: ensure selectors are memoized and return stable references (e.g., reuse arrays/objects when contents haven’t changed). Angular DevTools will show render counts dropping when identity is stable.
Container-first adapter
Convert a selector to a signal with a safe initial value. This avoids undefined at render/SSR and reduces change detection churn.
Code: NgRx selector ➜ signal
Introducing SignalStore without a big bang
// libs/filters/data-access/src/lib/filters.store.ts
import { signalStore, withState, withMethods, withComputed } from '@ngrx/signals';
import { computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { tap } from 'rxjs/operators';
export interface FiltersState {
query: string;
tags: string[];
loading: boolean;
}
const initialState: FiltersState = { query: '', tags: [], loading: false };
export const FiltersStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withComputed((store) => ({
hasQuery: computed(() => !!store.query()),
tagCount: computed(() => store.tags().length),
})),
withMethods((store) => {
const http = inject(HttpClient);
return {
setQuery(query: string) { store.query.set(query); },
toggleTag(tag: string) {
const next = new Set(store.tags());
next.has(tag) ? next.delete(tag) : next.add(tag);
store.tags.set([...next]);
},
loadTags() {
store.loading.set(true);
return http.get<string[]>('/api/tags').pipe(
tap(tags => { store.tags.set(tags); store.loading.set(false); })
).subscribe();
}
};
})
);Router-level canaries: mount this store only for a flagged route first. If metrics regress, flip back to NgRx in seconds.
Minimal SignalStore slice
Start with a thin slice—filters or user prefs—so rollback is trivial.
withState for data + status
withMethods for commands
withComputed for projections
Code: a SignalStore example
RxJS 7 refactors that save you from ghost bugs
// libs/core/data/src/lib/rxjs-fixes.ts
import { Observable, defer, timer, firstValueFrom } from 'rxjs';
import { shareReplay, retryWhen, scan, delayWhen } from 'rxjs/operators';
export function stableReplay<T>(source$: Observable<T>) {
// Ensure a single shared subscription and late subscribers get last value
return source$.pipe(
shareReplay({ bufferSize: 1, refCount: true })
);
}
export async function getOnce<T>(source$: Observable<T>): Promise<T> {
// Replace legacy toPromise
return firstValueFrom(source$);
}
export function withExponentialBackoff<T>(source$: Observable<T>, maxRetries = 5) {
return source$.pipe(
retryWhen(errors => errors.pipe(
scan((acc) => acc + 1, 0),
delayWhen((attempt) => timer(Math.min(1000 * 2 ** attempt, 15000)))
))
);
}// Typed WebSocket -> Observable -> Signal
import { webSocket } from 'rxjs/webSocket';
import { toSignal } from '@angular/core/rxjs-interop';
import { inject, effect } from '@angular/core';
interface PriceTick { symbol: string; bid: number; ask: number; ts: number; }
export function usePrices() {
const socket$ = stableReplay(
withExponentialBackoff(
webSocket<PriceTick>({ url: 'wss://prices.example', deserializer: e => JSON.parse(e.data) })
)
);
const prices = toSignal(socket$, { initialValue: { symbol: '', bid: 0, ask: 0, ts: 0 } });
// Optional: effect to persist samples or raise alerts
effect(() => {
const p = prices();
if (p.symbol && p.bid === 0) {
// Telemetry/alert here
}
});
return { prices };
}What breaks moving from Angular 12–15 ➜ 20
These are the changes that surface in tests first—but they also fix production memory leaks and race conditions.
toPromise removed: use firstValueFrom/lastValueFrom
shareReplay signature: prefer object form
catchError typing: must rethrow or return typed Observable
Code: stream stabilization
Typed WebSocket with retry/backoff
This pattern stabilized a broadcast media network’s VPS scheduler and multiple real-time dashboards.
Exponential backoff with jitter
Typed schemas to prevent runtime surprises
Change detection: keep zone.js or go zoneless?
// main.ts (Angular 20)
import { bootstrapApplication, provideExperimentalZonelessChangeDetection } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [
provideHttpClient(),
// Gate behind an env flag or Remote Config
...(environment.zoneless ? [provideExperimentalZonelessChangeDetection()] : [])
]
});If you see jitter or missed updates, flip the flag off and triage with flame charts. Don’t ship zoneless by default until metrics are green.
Default: keep zone.js while you migrate state
You’ll remove more detectChanges calls just by moving to computed()/signals than by flipping zoneless. Ship stability first.
Predictable; matches prod behavior
Signals reduce change frequency anyway
Experiment: gated zoneless mode
Zoneless can be great for kiosks (I built United’s with offline-tolerant flows), but only after state is clean.
Use provideExperimentalZonelessChangeDetection behind a flag
Watch Angular DevTools render counts and UX metrics
Code: bootstrap options
Guardrails: CI, telemetry, and linting that catch regressions
// .eslintrc.json (excerpt)
{
"overrides": [
{
"files": ["*.ts"],
"rules": {
"no-restricted-imports": ["error", { "paths": [{ "name": "rxjs", "importNames": ["toPromise"], "message": "Use first/lastValueFrom" }] }],
"rxjs/no-subject-value": "error",
"@angular-eslint/prefer-signals": "warn"
}
}
]
}CI and lint rules
If a file sneaks in detectChanges, tests should fail with a message pointing to the Signals path.
Ban toPromise and Subject-as-event-bus
Require shareReplay object syntax
Disallow ChangeDetectorRef.detectChanges in components
Telemetry
In gitPlumbers (my production app), this setup maintains 99.98% uptime through modernizations.
Angular DevTools for render counts
GA4 + Lighthouse for TTI/CLS
OpenTelemetry + Sentry for errors
Sample ESLint config
When to Hire an Angular Developer for Legacy Rescue
I’ve run this process at a global entertainment company (employee/payments tracking), a broadcast media network VPS scheduling, an insurance technology company telematics dashboards, and United kiosks with Docker-based hardware simulation.
Signals of risk that justify outside help
These are the patterns I see in chaotic codebases—often AI-generated. A short engagement to triage state and set adapters pays for itself quickly.
Core Web Vitals regress when enabling Signals
Effects or Subjects swallow errors silently
Manual detectChanges sprinkled across code
Typical engagement timeline
Directors/PMs: you get a clear rollback path and measurable KPIs, not a rewrite.
Discovery in 48 hours
Assessment in 5 business days
Canary upgrade in 2–4 weeks
Example: dashboard upgrade from jitter to stable
<!-- After: stable identity + Signals-driven inputs -->
<app-chart [series]="series()" [filters]="filtersStore.tags()"></app-chart>The change is visible in flame charts: fewer checks, fewer microtasks, more predictable UX.
Before
Charts re-rendered 10–20x per second on bursts.
AsyncPipe on multiple ReplaySubjects with no buffer
Objects recreated in map() on every tick
After
Render counts dropped 70% in Angular DevTools; Lighthouse improved TTI by 18%.
SignalStore for filters
Selectors -> toSignal with stable initial values
shareReplay({bufferSize:1,refCount:true}) on sources
Practical takeaways and next instrumentation
- Signals adoption is safest via adapters first, then targeted SignalStore slices.
- Fix RxJS semantics early (toPromise, shareReplay, catchError typing).
- Keep zone.js while stabilizing; only test zoneless behind a flag.
- Lock guardrails in CI so regressions can’t sneak in.
- Measure everything with Angular DevTools, GA4, and OpenTelemetry.
What to instrument next
Your upgrade is complete when the system is observable.
Add route-level feature flags for SignalStore slices
Track render counts per route in CI perf runs
Snapshot selector identity to catch churn
Ready to upgrade? Let’s talk
See how I stabilize legacy apps and rescue chaotic code: review your Angular roadmap, or hire an Angular consultant for a focused upgrade sprint.
Bring me in for a 2–4 week rescue or a full upgrade
If you need an Angular expert now, let’s review your repo and set a safe Signals migration path.
Remote, contractor/consultant
10+ years enterprise Angular
Live product proofs: gitPlumbers, IntegrityLens, SageStepper
Key takeaways
- Treat Signals adoption as an adapter phase, not a rewrite—bridge NgRx selectors and RxJS streams with toSignal() and SignalStore.
- RxJS 7 changes bite in shareReplay, toPromise removal, and typed catchError—fix these before enabling zoneless detection.
- Keep change detection stable by scoping Signals to containers first; verify with Angular DevTools and flame charts.
- Codify guardrails: ESLint rules for toPromise and Subject misuse, CI checks for cold vs hot streams, and deterministic test utilities.
- Use feature flags to canary SignalStore slices per route/tenant; measure regressions with GA4/Lighthouse and telemetry.
- Have a rollback plan: maintain parallel NgRx + SignalStore during migration so you can flip back instantly.
Implementation checklist
- nx migrate @angular/core@20 @angular/cli@20 && npm i
- Update RxJS: replace toPromise with first/lastValueFrom; audit shareReplay({refCount:true,bufferSize:1})
- Introduce @angular/core/rxjs-interop toSignal with stable initial values for SSR/tests
- Wrap NgRx selectors into signals for container components; leave reducers/effects intact initially
- Add one SignalStore slice with withState/withComputed/withMethods; canary behind feature flag
- Instrument Angular DevTools, GA4, and OpenTelemetry; track error rates and TTI
- Consider experimental zoneless only after green metrics; keep zone.js as fallback
- Lock in CI guardrails: ESLint, failing tests on unhandled error paths, and snapshot diffs for change detection
Questions we hear from teams
- How long does an Angular 12–15 ➜ 20 upgrade take?
- For state/RxJS/change detection only, plan 2–4 weeks for a canary rollout in a mature Nx workspace. Full-platform upgrades vary, but adapters let you ship incrementally without a rewrite.
- Do we need to replace NgRx with SignalStore?
- No. Keep NgRx where it excels and introduce SignalStore per slice. Bridge selectors with toSignal for containers. Many teams run both during migration and even long-term.
- Is zoneless change detection ready for production?
- Treat it as an experiment behind a feature flag. Stabilize state first. If metrics improve and tests pass, you can roll it out gradually with a fast rollback switch.
- What does an Angular consultant do during this migration?
- I assess state and streams, add adapters, implement 1–2 SignalStore slices, fix RxJS 7 pitfalls, add CI/telemetry guardrails, and set flags/rollbacks. You get measurable outcomes and a safe path forward.
- How much does it cost to hire an Angular developer for this work?
- Short, focused rescues are typically 2–4 weeks. I scope a fixed or time-and-materials engagement after a quick repo review; discovery call within 48 hours and assessment within 5 business days.
Ready to level up your Angular experience?
Let AngularUX review your Signals roadmap, design system, or SSR deployment plan.
NG Wave
Angular Component Library
A comprehensive collection of 110+ animated, interactive, and customizable Angular components. Converted from React Bits with full feature parity, built with Angular Signals, GSAP animations, and Three.js for stunning visual effects.
Explore Components