Angular 12–15 ➜ 20 Without State Surprises: Signals + SignalStore, RxJS 7 fixes, and change detection that won’t jitter

Angular 12–15 ➜ 20 Without State Surprises: Signals + SignalStore, RxJS 7 fixes, and change detection that won’t jitter

A senior, battle-tested path to upgrade Angular 12–15 apps to 20 without breaking state, streams, or UX.

Don’t rewrite to Signals—adopt them at the edges, harden RxJS, and only then consider zoneless. Boring steps ship. Rewrites don’t.
Back to all posts

I’ve upgraded Angular apps at a global entertainment company, a broadcast media network, United, and Charter where a single state mistake ripples into jittery dashboards or silent data loss. If you need a remote Angular developer to take your 12–15 app to 20, this is the playbook I use in the real world—Signals + SignalStore where it helps, RxJS where it shines, and change detection that doesn’t surprise your users.

As companies plan 2025 Angular roadmaps, the trap is trying to “rewrite to Signals” while also bumping RxJS and switching change detection. Don’t. Split the upgrade into three parallel tracks with guardrails, measure with Angular DevTools and GA4/Firebase, and ship behind feature flags in an Nx monorepo.

Below is a senior path I’ve run on enterprise dashboards (Charter ads analytics, an insurance technology company telematics, a global entertainment company internal tools) that preserved uptime and avoided regression. If you’re looking to hire an Angular expert who’s shipped these changes in production, this is how I’ll approach your codebase.

Why Angular 12 Apps Break During the Angular 20 Jump: RxJS, State, Change Detection

Common failure modes I actually see

On United’s kiosk work we learned the hard way: one “innocent” stream change can freeze a device. On dashboards at a leading telecom provider, careless shareReplay kept ghost sessions alive for hours. Angular 20 is fast—but only if we carry state and RxJS forward intentionally.

  • Selectors refire unpredictably after refactors

  • shareReplay memory leaks pin stale data

  • toPromise removal breaks guards/resolvers

  • Zone-tied side effects stop running under zoneless

  • SSR hydration mismatches when initial state isn’t stable

Three-track upgrade strategy

Decouple the concerns so you can ship partial wins weekly. Each track has its own tests and feature flags to reduce blast radius.

  • State: migrate selectors/facades to signals first

  • Streams: RxJS 6→7 mechanical fixes + hardening

  • Change detection: OnPush stability, optionally zoneless

How an Angular Consultant Approaches Signals Migration Without Rewrite

This gives you signal-driven templates now, while reducers and effects remain untouched. It’s the on-ramp to SignalStore.

Adopt signals at the edges

Signals are fantastic for view composition. Keep your NgRx reducers/effects intact initially. Migrate selectors/facades to signals so templates become deterministic without ripping out your state engine.

  • selectSignal() for store slices

  • toSignal() for service streams

  • computed() to replace selector math

  • effect() for imperative sync

Minimal facade example (NgRx ➜ Signals)

// users.facade.ts (Angular 20, NgRx 16+)
import { inject, Injectable, computed } from '@angular/core';
import { Store, selectSignal } from '@ngrx/store';
import { selectUsers, selectSelectedId } from './users.selectors';

@Injectable({ providedIn: 'root' })
export class UsersFacade {
  private store = inject(Store);
  users = selectSignal(this.store, selectUsers);
  selectedId = selectSignal(this.store, selectSelectedId);
  selectedUser = computed(() => this.users().find(u => u.id === this.selectedId()));
  select(id: string) { this.store.dispatch({ type: '[Users] Select', id }); }
}

SignalStore: Slice-by-Slice Adoption for NgRx Teams

Why SignalStore

For read-mostly dashboards (Charter ads, an insurance technology company telematics), SignalStore reduces boilerplate without sacrificing testability. I start with low-risk slices like filters or view preferences.

  • Colocation of state + computed + mutations

  • Ergonomic memoization via computed()

  • No runtime selector graph to debug

SignalStore example

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

export interface FiltersState { query: string; tags: string[]; }

export const FiltersStore = signalStore(
  withState<FiltersState>({ query: '', tags: [] }),
  withComputed(({ query, tags }) => ({
    isActive: () => !!query() || tags().length > 0,
  })),
  withMethods((store) => ({
    setQuery(q: string) { store.query.set(q); },
    toggleTag(t: string) {
      const next = new Set(store.tags());
      next.has(t) ? next.delete(t) : next.add(t);
      store.tags.set([...next]);
    }
  }))
);

Bridge period with NgRx effects

You can keep NgRx for async orchestration and gradually fold slices into SignalStore. This hybrid is boring—and boring is safe in production.

  • Keep effects for server I/O and WebSockets

  • Expose derived state as signals to components

RxJS 6→7 Breaking Changes You Must Touch

Run ESLint rules (rxjs/no-ignored-observable, rxjs/no-unsafe-takeuntil) and codemods early to keep diffs small.

Fix toPromise, prefer firstValueFrom/lastValueFrom

// guards/resolvers
import { firstValueFrom } from 'rxjs';
const user = await firstValueFrom(this.api.getUser(id));

Harden shareReplay to avoid leaks and stale caches

import { shareReplay } from 'rxjs/operators';

this.live$ = this.socket.stream('orders').pipe(
  // ensures teardown when no subscribers
  shareReplay({ bufferSize: 1, refCount: true })
);

  • Always specify bufferSize and refCount

  • Prefer refCount: true for live data

Multicasting & connectable changes

import { share } from 'rxjs/operators';
const updates$ = this.source$.pipe(share({ resetOnRefCountZero: true }));

  • Use share or connect operators from rxjs

Typed catchError and rethrow

import { catchError, throwError } from 'rxjs';

load$ = this.http.get<User>('/api/user').pipe(
  catchError(err => {
    this.logger.error('load failed', err);
    return throwError(() => err);
  })
);

  • Return typed Observable, don’t swallow errors

Change Detection Upgrades: OnPush to Signals, and When to Go Zoneless

Stabilize OnPush with signals first

import { Component, input, computed } from '@angular/core';

@Component({ selector: 'user-card', templateUrl: './user-card.html' })
export class UserCardComponent {
  user = input.required<User>();
  initials = computed(() => this.user().name.split(' ').map(s => s[0]).join(''));
}

  • Prefer signal inputs with input()

  • Replace async pipe churn with signals where hot

Gate zoneless change detection behind a flag

// main.ts
import { bootstrapApplication, provideZoneChangeDetection } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core'; // use the stable/experimental export available in your version

const zoneless = (window as any).__featureFlags?.zoneless === true;

bootstrapApplication(AppComponent, {
  providers: [
    zoneless ? provideZonelessChangeDetection() : provideZoneChangeDetection({ eventCoalescing: true, runCoalescing: true })
  ]
});

Roll out to 1–5% of traffic via Firebase Remote Config; watch Angular DevTools flame charts for unexpected rerenders and GA4 for CLS shifts.

  • Flip per-app, not per-component

  • Audit third-party libs (PrimeNG/Material)

SSR hydration sanity

If you SSR, make sure any toSignal() adapters have a deterministic initial value to avoid mismatched hydration.

  • Ensure stable initial values

  • Avoid side effects in constructors

Real-Time Dashboards: WebSockets and Typed RxJS→Signals Adapters

Keep network surfaces in RxJS, converge to signals for rendering. This pattern scaled our Charter analytics dashboards without jitter.

Typed event schema + backoff retry

import { webSocket } from 'rxjs/webSocket';
import { map, retryWhen, scan, delay } from 'rxjs/operators';

interface OrderEvent { id: string; status: 'new'|'filled'|'error'; ts: number; }

const socket$ = webSocket<OrderEvent>({ url: 'wss://events' });

const events$ = socket$.pipe(
  map(e => ({ ...e, ts: e.ts ?? Date.now() })),
  retryWhen(errors => errors.pipe(
    scan((acc) => Math.min(acc * 2, 16000), 1000),
    delay(v => v + Math.random() * 250)
  ))
);

  • Exponential retry with jitter

  • Validate payload shape before state write

Bridge to signals at the component boundary

import { toSignal } from '@angular/core/rxjs-interop';

orders = toSignal(events$, { initialValue: [] as OrderEvent[] });

// template: <orders-table [rows]="orders()"></orders-table>

CI Guardrails and Metrics That Catch Regressions

Nx + ESLint + Cypress minimal setup

# nx-cloud + GitHub Actions (excerpt)
name: ci
on: [pull_request]
jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
        with: { version: 9 }
      - run: pnpm i --frozen-lockfile
      - run: pnpm nx affected -t lint,test,build --parallel=3
      - run: pnpm nx e2e app-e2e --configuration=ci

  • affected:test on every PR

  • ESLint rules for rxjs/angular

  • E2E happy-path + hydration smoke

Telemetry you should watch

In IntegrityLens (12k+ interviews), we shipped upgrades behind feature flags and watched error budgets. In gitPlumbers (99.98% uptime), we block merges if RxJS lint errors or SSR hydration mismatches appear.

  • Angular DevTools flame charts

  • Core Web Vitals (CLS, INP)

  • GA4/Firebase Logs for error rates

When to Hire an Angular Developer for Legacy Rescue

Signals you need help now

If this feels familiar, bring in an Angular consultant who’s done it at enterprise scale. I’ll audit your state, streams, and change detection in a week and give you a flagged rollout plan that engineering and product can both trust.

  • Repeated regressions after each update

  • WebSocket disconnect storms under load

  • Zone-related race conditions in prod

  • Developers avoiding certain files/components

Concrete Outcomes and Next Steps

What you’ll get in 2–4 weeks

On past rescues we restored stability, cut dashboard re-renders by 30–45%, and unblocked upgrades without freezing delivery.

  • RxJS 7 fixed and linted

  • Selectors shifted to signals

  • One SignalStore slice in prod

  • Zoneless evaluated behind feature flag

  • Telemetry and rollback guardrails

What to instrument next

Once the backbone is stable, we extend measurement so regressions get caught before users ever see them.

  • Feature-flagged SSR hydration checks

  • PrimeNG/Material visual regression tests

  • OpenTelemetry traces across effects

Related Resources

Key takeaways

  • Treat the upgrade as three tracks: state (Signals/SignalStore), streams (RxJS 7), and change detection (OnPush → signals, zoneless optional).
  • Migrate selectors and facades first; fold reducers last. Use SignalStore to bridge NgRx safely.
  • Fix RxJS 6→7 breaking changes upfront: toPromise removal, shareReplay config, connectable/multicast, typed catchError.
  • Adopt signals at boundaries: selectSignal(), toSignal(), computed(), effect(). Keep network as RxJS until stable.
  • Introduce zoneless behind a feature flag; validate async pipes, microtasks, and third‑party libs before flipping globally.
  • Instrument everything: Angular DevTools profiles, Core Web Vitals, GA4/Firebase Logs, and feature-flagged canaries in Nx CI.

Implementation checklist

  • nx migrate latest && ng update @angular/core @angular/cli@20 @angular/material prime versions
  • Upgrade RxJS to 7.x and run rxjs-tslint-to-eslint or codemods for toPromise, shareReplay, multicasting
  • Replace Store.select(...) usages with selectSignal() or toSignal() adapters
  • Introduce SignalStore for one slice; keep NgRx reducers/effects in place—no big-bang rewrite
  • Harden WebSockets: typed events, backoff retry, and shareReplay({ bufferSize: 1, refCount: true })
  • Gate zoneless change detection with a feature flag; run E2E, SSR hydration, and A/B profiling
  • Add ESLint rules for rxjs/no-ignored-observable and angular/no-zone-run
  • Verify UX metrics: CLS < 0.1, TTI stable, no unexpected re-renders in Angular DevTools flame charts

Questions we hear from teams

How long does an Angular 12–15 to 20 upgrade take?
For a typical enterprise dashboard, 2–4 weeks for the core upgrade, RxJS fixes, and signals at the edges. Add 1–2 weeks to evaluate zoneless and migrate a first SignalStore slice behind flags.
Do we need to rewrite NgRx to use Signals?
No. Start by exposing selectors as signals with selectSignal() and toSignal(). Migrate low-risk slices to SignalStore over time. Keep effects for I/O and WebSockets until the new state boundary proves stable.
Will zoneless break our third-party components?
It can. Roll it out behind a feature flag, verify PrimeNG/Material behavior, and run E2E plus Angular DevTools profiling. Keep coalescing enabled if you stick with zone.js.
What does an Angular consultant engagement include?
Assessment within a week, a flagged rollout plan, codemods for RxJS 7, signals adoption plan, and CI guardrails. I pair with your team to ship safely and leave you with maintainable patterns.
How much does it cost to hire an Angular developer for this work?
Depends on scope and risk. Most 2–4 week upgrades are a fixed price with clear deliverables. Book a discovery call and I’ll scope it within 48 hours based on repo size and complexity.

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 Request a 30‑minute Angular 20 Upgrade Review

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