Angular 12–15 to 20: Surviving State, RxJS 8, and Zoneless Change Detection with Signals + SignalStore

Angular 12–15 to 20: Surviving State, RxJS 8, and Zoneless Change Detection with Signals + SignalStore

A practical upgrade path I use on enterprise dashboards: migrate NgRx selectors to Signals, bridge RxJS 8 safely, and turn on zoneless change detection without breaking prod.

“Bridge selectors to Signals first. The fastest wins happen when you reduce churn at the component edge—then land SignalStore and zoneless with metrics and flags.”
Back to all posts

I’ve upgraded a lot of Angular apps in the wild—telecom advertising analytics, airport kiosks with offline flows, insurance telematics dashboards. The pattern repeats: Angular 12–15 jumps to 20, then state breaks, streams get noisy, and change detection either thrashes or goes silent. Here’s how I handle it in real projects with Signals + SignalStore, RxJS 8, and zoneless change detection—without lighting production on fire.

As companies plan 2025 Angular roadmaps, teams want the productivity of Signals and the performance benefits of zoneless CD. If you need a remote Angular developer or an Angular consultant to map this upgrade, this is the playbook I actually run in enterprise codebases (Nx monorepos, PrimeNG/Material, Firebase, CI/CD).

The moment your Angular 12 dashboard hits Angular 20 reality

On a telecom analytics platform, the upgrade PR looked green. Then dashboards jittered, WebSocket tiles stalled, and a couple of PrimeNG components stopped reflecting updates. Root causes:

  • RxJS deprecations finally removed in v8

  • Components glued together with async pipe + change detection quirks

  • NgRx selectors chained into heavy component logic

We stabilized it by introducing Signals at the edges first, then refactoring the hot paths into SignalStore. Rendering went zoneless behind a feature flag, guarded by Cypress tests and Firebase Hosting previews.

Why Angular 12 apps break during Signals, RxJS, and change detection upgrades

Typical breakpoints I see in the field

Signals change how we think about propagation. If selectors or effects are noisy, Signals will faithfully propagate that noise. With RxJS 8 removing older patterns, small leaks and retries suddenly matter. Combine those with zoneless change detection and you’ll expose hidden dependency issues fast.

  • Deprecated RxJS APIs (toPromise, result selectors) removed in RxJS 8

  • Selectors doing too much work per tick; async pipe churn

  • Zone-dependent 3rd‑party components misbehaving when zoneless is enabled

  • Tight loops in effects causing unnecessary re-renders

Why it matters in Angular 20+

In production dashboards—ads analytics, telematics, device management—this translates to lower CPU on busy tiles, fewer missed frames, and more deterministic UI under load.

  • Signals + zoneless can cut re-render cost and INP by double-digits when done right

  • RxJS 8 removes footguns; correct patterns reduce memory and CPU

  • SignalStore gives local-first, testable state without full reducer ceremony

Step-by-step: migrate state, RxJS, and change detection safely

1) Pin toolchain and baseline metrics

Before touching code, I pin versions in Nx and capture a ‘before’ snapshot. This becomes our success metric and rollback bar.

  • Lock Angular/TypeScript/RxJS in package.json + pnpm-lock.yaml

  • Use Angular DevTools to capture flame charts and re-render counts

  • Track INP/LCP via Lighthouse CI; capture error rates in Firebase

2) RxJS 8 migration: fix removals and noisy streams

These changes defuse most runtime surprises. I also scan effects/selectors for accidental nested subscriptions and replace them with switchMap/concatMap as appropriate.

  • Replace toPromise with lastValueFrom/firstValueFrom

  • Prefer subscribe({ next, error, complete }) for clarity

  • Use retry with a config object and exponential backoff

  • Harden shareReplay to avoid leaks

Code: common RxJS 8 fixes

// before
const result = await api$.toPromise();

// after
import { lastValueFrom, retry, timer, shareReplay } from 'rxjs';
const result = await lastValueFrom(api$.pipe(
  retry({ count: 3, delay: (_, i) => timer(Math.min(5000, 2 ** i * 500)) })
));

// safer caching for hot stores
const cached$ = source$.pipe(
  shareReplay({ bufferSize: 1, refCount: true })
);

// explicit observer form prevents silent type breaks
source$.subscribe({
  next: v => console.log(v),
  error: e => console.error(e),
  complete: () => console.log('done')
});

3) Introduce Signals at the component edge

NgRx 16+ exposes selectSignal; you get immediate wins without refactoring reducers/effects.

  • Bridge existing selectors to signals

  • Replace targeted async pipes with signals

  • Measure re-render deltas in Angular DevTools

Code: selectors to signals

// component.ts
import { Component, computed, inject, DestroyRef } from '@angular/core';
import { Store } from '@ngrx/store';
import { selectUsers, selectLoading, selectError } from './state';

@Component({
  selector: 'app-users',
  template: `
    <p-progressSpinner *ngIf="loading()"></p-progressSpinner>
    <p *ngIf="error()" class="error">{{ error() }}</p>
    <ul>
      <li *ngFor="let u of users()">{{ u.name }}</li>
    </ul>
  `
})
export class UsersComponent {
  private store = inject(Store);
  users = this.store.selectSignal(selectUsers);
  loading = this.store.selectSignal(selectLoading);
  error = this.store.selectSignal(selectError);

  // Derived state with Signals
  userCount = computed(() => this.users().length);
}

4) Move hot paths into SignalStore

I usually keep NgRx effects for server orchestration and use SignalStore for view-local state. It reduces boilerplate and keeps updates deterministic.

  • Local-first state with computed selectors

  • Side-effect methods with explicit, testable logic

  • Great for dashboards, edit forms, and multi-tenant slices

Code: SignalStore slice with computed selectors

// users.store.ts
import { signalStore, withState, withMethods, withComputed } from '@ngrx/signals';

interface UsersState {
  entities: Record<string, { id: string; name: string; role: string }>;
  loading: boolean;
}

const initialState: UsersState = { entities: {}, loading: false };

export const UsersStore = signalStore(
  withState(initialState),
  withComputed(({ entities }) => ({
    all: () => Object.values(entities()),
    admins: () => Object.values(entities()).filter(u => u.role === 'admin')
  })),
  withMethods((store) => ({
    setLoading(v: boolean) { store.loading.set(v); },
    upsertMany(users: UsersState['entities']) {
      store.entities.update(prev => ({ ...prev, ...users }));
    }
  }))
);

5) Bridge WebSocket streams to Signals cleanly

For real-time tiles (I do a lot with D3/Highcharts), I keep RxJS for transport and convert to Signals at the boundary.

  • Use toSignal with initialValue for safe templates

  • Cancel streams with takeUntilDestroyed for services

  • Typed event schemas to prevent UI drift

Code: WebSocket → Signal with cleanup

// telemetry.service.ts
import { inject, Injectable, DestroyRef } from '@angular/core';
import { webSocket } from 'rxjs/webSocket';
import { toSignal } from '@angular/core/rxjs-interop';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

@Injectable({ providedIn: 'root' })
export class TelemetryService {
  private destroyRef = inject(DestroyRef);
  private events$ = webSocket<{ type: string; value: number }>({ url: 'wss://example/ws' })
    .pipe(takeUntilDestroyed(this.destroyRef));

  events = toSignal(this.events$, { initialValue: { type: 'init', value: 0 } });
}

6) Enable zoneless change detection behind a flag

Most enterprise apps can go zoneless incrementally. If a library needs zone callbacks, drive UI updates with Signals or wrap callbacks with runInInjectionContext/effect.

  • Start with app shell or a single route

  • Verify 3rd‑party libraries (PrimeNG/Material) still render on async callbacks

  • Measure INP and re-render counts before/after

Code: opt into zoneless in Angular 20

// main.ts
import { bootstrapApplication, runInInjectionContext } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';
import { provideZonelessChangeDetection } from '@angular/core';

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes),
    provideHttpClient(withFetch()),
    provideZonelessChangeDetection(), // Angular 19+; use provideExperimentalZonelessChangeDetection() on earlier
  ]
});

7) CI guardrails and canaries

I never flip the whole app at once. Canary a route (e.g., /dash/v2), push traffic gradually, and keep a one-click rollback.

  • Nx affected tests, Cypress smoke on canary routes

  • Firebase Hosting previews per PR; merge gates on Lighthouse INP

  • Feature flags for zoneless and Signals paths

Real upgrade playbook from a telecom analytics dashboard

Context

We inherited a production app with async-pipe-heavy change detection and ‘creative’ selectors.

  • Angular 13 → 20, Nx monorepo, PrimeNG, Firebase Hosting previews

  • WebSocket-heavy tiles, multi-tenant state, role-based filters

Actions

We instrumented Angular DevTools and GA4 to compare re-render counts and INP over 7 days.

  • Patched RxJS 8 removals; added safe retry/backoff

  • Introduced selectSignal on hot components

  • Moved dashboard-local state to SignalStore

  • Enabled zoneless on the dashboard route only

Results (measured)

This is the pattern I now standardize across AngularUX demos and client work.

  • -27% median INP on dashboard route

  • ~40% fewer template re-renders on busy tiles

  • Zero production incidents; two-minute rollback path via Firebase

How an Angular Consultant approaches Signals migration

My sequence

If you want a senior Angular engineer to lead this, I’ll review your repo and ship a concrete plan in a week. See NG Wave for my Signals-first components and motion patterns.

  • Baseline metrics and pin toolchain

  • RxJS 8 fixes first (it’s the lowest-risk, highest-signal change)

  • Bridge selectors to signals; measure hot paths

  • Introduce SignalStore for local slices

  • Flip zoneless behind flags; validate libraries

Hiring indicators

If that sounds familiar, you likely need an Angular expert who has done this in production.

  • Critical dashboards stutter under load

  • RxJS deprecations blocking TypeScript upgrades

  • Zone.js hacks scattered across components

  • Multi-tenant state tangled in component trees

Takeaways and what to instrument next

  • Start with RxJS 8 corrections, then bridge selectors to Signals.

  • Use SignalStore for high-churn local state; keep NgRx for server orchestration if it’s working.

  • Enable zoneless incrementally; validate PrimeNG/Material and custom directives.

  • Measure everything with Angular DevTools, Lighthouse INP, and Firebase logs.

If you want a quick assessment or need to hire an Angular developer who’s shipped these upgrades for Fortune 100 teams, I’m available for select remote engagements.

Common questions on upgrading Angular 12–15 to 20

Do I need to replace NgRx entirely with Signals?

No. Use Signals where they shine (local UI, derived state) and keep NgRx for complex effects and orchestration. NgRx 16+ supports selectSignal and SignalStore—best of both worlds.

Will zoneless break my PrimeNG/Material components?

Most components work if updates are driven by Signals or explicit callbacks. Test critical widgets behind a feature flag; wrap library callbacks with runInInjectionContext if needed.

What’s a realistic timeline?

For medium dashboards, I plan 2–3 weeks for RxJS + Signals bridging, 1–2 weeks for SignalStore refactors, and 1 week for zoneless canaries and rollout—parallelized with CI.

Related Resources

Key takeaways

  • Treat the upgrade as three tracks: state (Signals + SignalStore), streams (RxJS 8), and rendering (zoneless change detection).
  • Bridge incrementally: expose existing NgRx selectors as signals before refactoring reducers/effects.
  • Fix RxJS 8 removals early: replace toPromise, use retry config, and safe shareReplay patterns to avoid leaks.
  • Turn on zoneless with measurement: instrument INP, CPU time, and re-render counts in Angular DevTools before/after.
  • Guard the rollout: feature flags, canary routes, Firebase Hosting previews, and CI gates (Cypress + Lighthouse).

Implementation checklist

  • Pin toolchain in Nx/lockfiles before touching code.
  • Adopt Signals in components using store.selectSignal and toSignal.
  • Refactor deprecated RxJS APIs: toPromise → lastValueFrom, retry(count) → retry({count}).
  • Replace async pipe where safe with signals (metric-critical views first).
  • Introduce SignalStore for hot paths and computed selectors.
  • Enable provideZonelessChangeDetection() behind a feature flag; test PrimeNG/Material behavior.
  • Instrument telemetry: Angular DevTools profiler, INP, GA4/Firebase logs for errors and long tasks.
  • Use canary releases and Firebase Hosting previews; wire rollback in CI.

Questions we hear from teams

How long does an Angular 12–15 → 20 upgrade take?
Typical engagements run 4–6 weeks: 1–2 weeks for RxJS 8 fixes, 1–2 weeks to bridge selectors to Signals, 1 week to introduce SignalStore, and 1 week for zoneless canaries and rollout.
Do we have to replace NgRx with Signals?
No. Keep NgRx for server orchestration and adopt Signals for component-local state. Use store.selectSignal to bridge today and gradually move hot slices to SignalStore.
Will zoneless change detection break third‑party components?
Usually not, if you drive updates with Signals. Validate PrimeNG/Material components behind a feature flag and wrap external callbacks using runInInjectionContext or effects where needed.
What does an Angular consultant do during the upgrade?
I assess state and stream complexity, patch RxJS 8 issues, introduce Signals/SignalStore, set up zoneless flags, and wire CI canaries with Cypress and Lighthouse. You get a measured plan and rollback safety.
How much does it cost to hire an Angular developer for this upgrade?
Budgets vary by scope, but most mid-size upgrades fit a 4–6 week engagement. I provide a fixed-scope proposal after a repo review and can start within 1–2 weeks for qualified teams.

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 Get a fast review of your Angular 20 upgrade 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
NG Wave Component Library

Related resources