Angular 12–15 ➜ 20: Signals, NgRx→SignalStore, RxJS 7 fixes, and change detection that won’t break prod

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

Related Resources

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.

Hire Matthew – Remote Angular Expert (Available Now) Review your Angular 20 Signals migration 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