Angular 12–15 to 20 Without Surprises: Signals, RxJS 7.8+, and Change Detection Pitfalls (and Fixes)

Angular 12–15 to 20 Without Surprises: Signals, RxJS 7.8+, and Change Detection Pitfalls (and Fixes)

A senior-level field guide to upgrading enterprise Angular apps to v20—stabilize state, fix RxJS breakages, and avoid change‑detection thrash.

Upgrades fail at the edges—state, streams, and change detection. Stabilize those, and Angular 20 becomes a routine release.
Back to all posts

I’ve upgraded Angular apps in industries where production can’t flinch—airport kiosks (offline-first, hardware in the loop), global entertainment payroll, and telecom ad analytics dashboards that ingest millions of events. Moving 12–15 to 20 is doable without drama if you stabilize three fronts: state, RxJS, and change detection.

Below is the concise playbook I use as a remote Angular consultant to get teams through v20 with green metrics and no late-night rollbacks.

The dashboard that shakes when you click filters

Upgrades fail at the edges—state transitions during streaming, shared RxJS caches, and components doing too much work per tick. Angular 20 doesn’t require heroics, it requires discipline.

Scene from the trenches

A Fortune 100 analytics dashboard on Angular 13. Filters jitter, WebSocket streams drop on route change, and a few components still call toPromise. Stakeholders want Angular 20 for Signals and faster SSR, but fear a rewrite. We shipped the upgrade by isolating RxJS breakages, layering Signals on top of NgRx, and defusing change detection hotspots with OnPush + SignalStore.

Why Angular 12–15 apps break during upgrades

This is where a senior Angular consultant earns their keep: sequencing the work so each risk is isolated and measured.

Three destabilizers

If you don’t address these, you’ll see double subscriptions, stale caches, and components locked in ‘loading forever’ states. In 2025 roadmaps, you want Signals’ ergonomics without re-architecting everything at once.

  • RxJS 7.8+ semantics (shareReplay, throwError, toPromise removal)

  • State layering (NgRx selectors feeding OnPush templates + async pipes)

  • Change detection shifts (Signals + effects; optional zoneless)

Implementation checklist: upgrade to Angular 20 without breaking state

# framework + tooling
nvm use 20
ng update @angular/core@20 @angular/cli@20
ng update rxjs@^7.8
ng update @ngrx/store @ngrx/effects @ngrx/entity @ngrx/router-store

# lint + tests keep you honest
ng add @angular-eslint/schematics
nx affected:test --all
nx affected:lint --all

Key notes:

  • Typescript 5+; strict true recommended.
  • Fix builder changes if you’re still on webpack-only builders; Nx v18+ + Vite work well.
  • PrimeNG upgrade? Align with Angular 20; validate tokens and breaking CSS utilities in Storybook/Chromatic.

1) Preflight and CLI moves

Upgrade frameworks and baselines first. Keep a rollback branch and CI canary.

Commands

Fix RxJS breakages before you touch state

// BEFORE (Angular 13)
const cached$ = this.http.get<Item[]>('/api/items').pipe(
  shareReplay(1)
);

const result = await this.http.get('/api/do').toPromise();

// AFTER (Angular 20 / RxJS 7.8)
const cached$ = this.http.get<Item[]>('/api/items').pipe(
  shareReplay({ bufferSize: 1, refCount: true })
);

const result = await firstValueFrom(this.http.get('/api/do'));

// throwError factory + tap observer
pipe(
  catchError((e) => throwError(() => e)),
  tap({ next: v => log(v), error: e => track(e) })
)

Also ensure WebSocket subjects are closed on destroy and reconnected with exponential backoff (retryWhen) to avoid zombie streams on route changes.

Common offenders

Tighten these and flaky loads disappear. Your state layer will thank you.

  • toPromise removed → use firstValueFrom / lastValueFrom

  • throwError signature → factory form throwError(() => err)

  • shareReplay(1) → shareReplay({ bufferSize: 1, refCount: true })

  • tap(next, error, complete) → tap({ next, error, complete })

Code transforms

Migrate state safely: NgRx to Signals without a rewrite

// selector -> signal adapter (Angular 20)
import { toSignal } from '@angular/core/rxjs-interop';

readonly orders = toSignal(this.store.select(selectOrders), { initialValue: [] });
readonly user   = toSignal(this.store.select(selectUser));

// template (no async pipe needed)
// <div>Total: {{ orders().length }}</div>

// Local state via SignalStore (NgRx Signals Store)
import { signalStore, withState, withComputed, withMethods } from '@ngrx/signals';

interface FilterState { query: string; sort: 'asc'|'desc'; }

export const FilterStore = signalStore(
  withState<FilterState>({ query: '', sort: 'asc' }),
  withComputed(({ query }) => ({
    debouncedQuery: computed(() => query().trim().toLowerCase())
  })),
  withMethods((store) => ({
    setQuery(q: string) { store.query.set(q); },
    toggleSort() { store.sort.update(s => s === 'asc' ? 'desc' : 'asc'); }
  }))
);

Keep NgRx Effects for cross-cutting concerns (auth, workspace, multi-tenant routing). Use SignalStore for page/local orchestration and derived UI state. This hybrid model is predictable and easy to review in PRs.

Adapter-first strategy

This lets you ship features while modernizing. It’s how we stabilized employee tracking for a global entertainment company and a telecom analytics platform without pausing delivery.

  • Keep NgRx store for app-wide domains; add Signals for component/local state.

  • Wrap selectors with toSignal to feed templates without async pipe churn.

  • Use SignalStore for local state orchestration and derived values.

Selector adapters with toSignal

Local state with SignalStore

Change detection: keep OnPush, trial zoneless later

// opt-in zoneless behind a flag (as of v20, experimental)
import { isDevMode, inject } from '@angular/core';
import { provideExperimentalZonelessChangeDetection } from '@angular/core';

export const appConfig: ApplicationConfig = {
  providers: [
    ...(featureFlags.zoneless ? [provideExperimentalZonelessChangeDetection()] : [])
  ]
};

<!-- Prefer signals/computed over heavy pipes -->
<div *ngFor="let o of filteredOrders(); trackBy: trackId">{{ o.total | currency }}</div>

Use watch() or effect() for imperative interop; keep side effects out of templates. Measure with Angular DevTools and GA4/Firebase Performance traces.

Pragmatic guidance

Angular DevTools flame charts are your friend. Profile before/after and pin budgets in CI.

  • OnPush + Signals is already fast and stable for v20.

  • Zoneless is still optional; adopt behind a flag and telemetry.

  • Use trackBy, stable references, and immutable updates to avoid accidental re-renders.

Feature-flagging zoneless

Template patterns that break

Refactor expensive pipes into computed signals and memoize derived data.

  • Mutating arrays/objects in-place without set/update triggers

  • Async pipe on hot streams causing extra CD passes

  • Expensive pipes in bindings (prefer computed signals)

Worked example: from NgRx + async pipe to Signals + Store

// BEFORE: container component
orders$ = this.store.select(selectOrders);
loading$ = this.store.select(selectLoading);

ngOnInit() {
  this.sub = this.route.params.pipe(
    switchMap(({ id }) => this.service.connect(id)), // websocket
  ).subscribe();
}

ngOnDestroy() { this.sub?.unsubscribe(); }

<!-- BEFORE -->
<button (click)="refresh()" [disabled]="(loading$ | async) ?? false">Refresh</button>
<div *ngFor="let o of (orders$ | async); trackBy: trackId">{{o.id}}</div>

// AFTER: Signals + interop
import { toSignal } from '@angular/core/rxjs-interop';
import { effect } from '@angular/core';

orders = toSignal(this.store.select(selectOrders), { initialValue: [] });
loading = toSignal(this.store.select(selectLoading), { initialValue: false });

constructor(private service: WsService, private route: ActivatedRoute) {
  effect(() => {
    const id = this.route.snapshot.params['id'];
    this.service.connect(id); // service manages reconnect/backoff
  });
}

refresh() { this.store.dispatch(loadOrders()); }

<!-- AFTER -->
<button (click)="refresh()" [disabled]="loading()">Refresh</button>
<div *ngFor="let o of orders(); trackBy: trackId">{{ o.id }}</div>

We moved subscription management out of the template, removed extra CD passes, and made the route→stream link explicit with an effect(). In production, I add exponential backoff and typed event schemas for WebSocket payloads.

Before (Angular 13)

After (Angular 20)

When to Hire an Angular Developer for Legacy Rescue

See how I stabilize chaotic codebases at gitPlumbers—our modernization system drove a verified 70% velocity boost while maintaining 99.98% uptime.

Bring in help if you see this

I’ve stabilized kiosk apps with Docker-based hardware simulation, multi-tenant analytics dashboards, and insurance telematics portals. If you need an Angular expert who can upgrade and steady delivery, I’m available for remote engagements.

  • toPromise scattered across codebase; mixed RxJS 6/7 styles

  • Heavy async pipe usage causing UI churn; unclear ownership of subscriptions

  • Global state for everything; no local orchestration; flaky tests

  • Desire to trial zoneless but lack of telemetry/guardrails

How an Angular Consultant Approaches Signals Migration

# excerpt: .github/workflows/ci.yml
- name: Build & Test
  run: |
    nx run-many -t lint,test,build --all
    npx lighthouse-ci https://preview-url --budgets-path=./budgets.json
    nx e2e app-e2e --configuration=ci

Phased plan

Every step is observable in metrics: change-detection cycles down, TTI and LCP stable, error rates trending to zero.

  • Week 1: Audit + upgrade RxJS/NgRx, map high-churn components

  • Week 2: Add Signals adapters + SignalStore to 1–2 features

  • Week 3–4: Expand coverage, trial zoneless behind flags, add CI quality gates

CI guardrails (Nx + GitHub Actions)

Telemetry makes upgrades boring—in the best way.

  • Cypress smoke + a11y, Lighthouse budgets, Jest/Karma coverage thresholds

  • Angular DevTools traces; Firebase Performance marks on critical flows

Measurable outcomes and what to instrument next

Once stable, explore SSR or partial hydration, and expand Signals to replace brittle component subscriptions. If you’re on PrimeNG, lock tokens and accessibility checks (AA) in CI for confidence.

Targets I hold teams to

Track with Angular DevTools flame charts, GA4/Firebase Performance, and Sentry/Datadog for error budgets.

  • CD cycles per route <= baseline ±5% after upgrade

  • LCP / TTI within 5% of pre-upgrade (or improved)

  • Zero unhandled stream errors in logs; retry telemetry visible

  • Template checks: no async pipe on hot streams; heavy pipes migrated

Related Resources

Key takeaways

  • Inventory and fix RxJS 7+ breakages first: toPromise removal, throwError factory, shareReplay options, typed interceptors.
  • Introduce Signals via adapters—not a rewrite. Wrap NgRx selectors with toSignal and migrate feature-by-feature.
  • Keep OnPush; consider zoneless behind a feature flag only after Signals stabilization.
  • Use SignalStore for local, component-scoped state; keep NgRx for cross-cutting, persisted, or event-sourced domains.
  • Guard the upgrade with CI: Angular DevTools checks, Cypress a11y, Lighthouse budgets, and contract tests.

Implementation checklist

  • ng update @angular/core@20 @angular/cli@20 && ng update rxjs@^7.8 && ng update @ngrx/*
  • Search and replace deprecated RxJS: toPromise→firstValueFrom/lastValueFrom; throwError(()=>err); shareReplay({bufferSize:1,refCount:true})
  • Introduce Signals adapters: toSignal(selectors$); convert hot UI streams with effect() and watch()
  • Retain OnPush; only trial zoneless behind a feature flag and Telemetry toggle
  • Upgrade NgRx and run migrations; keep facades; add SignalStore for local state
  • Replace template-heavy async pipes with signals reading (orders())
  • Instrument with Angular DevTools, GA4/Firebase Performance; track CD cycles and TTI

Questions we hear from teams

How long does an Angular 12–15 → 20 upgrade take?
Typical: 2–4 weeks for small apps, 4–8 weeks for enterprise dashboards. We start with RxJS/NgRx fixes, add Signals adapters, and gate releases with CI. I provide a written assessment within 1 week.
What’s involved when I hire an Angular consultant for this upgrade?
Discovery, code audit, upgrade plan, PRs with Signals/RxJS fixes, CI guardrails, and a rollback plan. I run workshops for your team and leave documentation so you’re self-sufficient.
Do we need to rewrite NgRx to adopt Signals?
No. Keep NgRx for global domains and wrap selectors with toSignal. Use SignalStore for local state. Migrate feature-by-feature without a risky rewrite.
Should we adopt zoneless change detection with Angular 20?
Only behind a feature flag with telemetry. OnPush + Signals is fast. Trial zoneless on a non-critical route, measure with Angular DevTools, then expand if metrics hold.
How much does it cost to hire an Angular developer for this work?
It depends on scope and testing maturity. I offer fixed-scope upgrade sprints and month-to-month retainers. Discovery call within 48 hours; detailed estimate after a code audit.

Ready to level up your Angular experience?

Let AngularUX review your Signals roadmap, design system, or SSR deployment plan.

Hire Matthew – Remote Angular Expert, Available Now See how we rescue chaotic Angular code

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
NG Wave Component Library

Related resources