Upgrade Angular 12–15 to Angular 20 Without Surprises: State, RxJS, and Change Detection Breakers (and Fixes)

Upgrade Angular 12–15 to Angular 20 Without Surprises: State, RxJS, and Change Detection Breakers (and Fixes)

A senior engineer’s playbook to move legacy Angular dashboards to 20+: stabilize NgRx, tame RxJS, adopt Signals/SignalStore, and keep SSR/tests deterministic.

Signals don’t fix leaky streams—you do. Stabilize RxJS and NgRx first, then let Signals make updates explicit and tests deterministic.
Back to all posts

I’ve upgraded jittery Angular 12–15 dashboards for telecom, media, and insurance—where a broken selector or a chatty stream can crater INP and burn a sprint. The good news: Angular 20’s Signals + SignalStore tame change detection and make SSR/tests deterministic. The catch: legacy RxJS and NgRx patterns will bite unless you stage the migration.

Below is the state-and-RxJS-first plan I use on enterprise apps (the same discipline behind NG Wave and my client upgrades). If you’re evaluating whether to hire an Angular developer or bring in an Angular consultant, this lays out the exact steps, code, and guardrails I’d run in your repo.

The Upgrade Scene—and Why It Matters in 2025

A familiar dashboard failure mode

You flip the switch on an Angular 15→20 upgrade. Charts mount, then jitter. A PrimeNG table re-renders on every WebSocket tick. SSR passes locally but hydration flaps in CI. I’ve seen this across a telecom analytics platform, an airline kiosk admin, and an insurance telematics dashboard. Root cause: state + RxJS patterns that fight Signals and modern change detection.

Why Angular 20 changes the calculus

Angular 20 gives you explicit, testable reactivity and smaller dependency surfaces. But 12–15 code often leans on implicit zone triggers, leaky Subjects, and over-eager shareReplay—all of which become magnified when Signals take the wheel.

  • Signals/computed/effect for deterministic UI updates

  • SignalStore for ergonomic local/global state

  • Functional providers (provideHttpClient, provideRouter)

  • Coalesced/zoneless change detection options

Why Angular 12–15 Apps Break During Signals Migration

RxJS pitfalls that surface under Signals

When Signals own the UI, stale Observables keep firing behind the scenes. shareReplay(1) without refCount will pin old data forever; Subject-as-store patterns make SSR nondeterministic. Fix these before introducing toSignal/fromObservable.

  • toPromise deprecation and dangling subscriptions

  • shareReplay without refCount causes memory bloat

  • Subjects used as stores with no completion/backpressure

  • switchMap storms and unbounded retries

NgRx churn between 12–20

Old effects entangle test setup and DI; selectors over-emphasize stringly-typed props. Angular 20 makes it easy to pivot to functional providers and SignalStore, but you must stage the move to avoid a big-bang rewrite.

  • Effects bound to constructors vs functional createEffect

  • Selector sprawl vs signal-based computed

  • Hybrid module bootstraps vs provideStore/provideEffects

Change detection expectations shift

Apps that relied on zone-patched events to ‘just update’ need an explicit plan. Start with coalescing, then graduate to zoneless only when Signals or manual marks cover all UI paths.

  • OnPush remains valuable—but Signals become the primary notifier

  • Coalesced change detection reduces redundant checks

  • Zoneless requires explicit triggers and Signal-driven updates

How an Angular Consultant Approaches a Signals‑First Upgrade (12–15 → 20)

# Step through majors if you’re <15; otherwise jump with CI gates
# Angular CLI/Framework
ng update @angular/cli@20 @angular/core@20

# With Nx
npx nx migrate @angular/core@20 @angular/cli@20
npx nx migrate --run-migrations

# Verify RxJS and TS
npm i rxjs@7.8.x typescript@~5.x --save-exact

// main.ts: functional providers + coalesced change detection
bootstrapApplication(AppComponent, {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideRouter(routes),
    provideHttpClient(withInterceptors([loggingInterceptor])),
  ],
});

1) Pin, migrate, and lock the toolchain

Use ng update or Nx migrate in controlled steps; run schematics but keep scope small. Add smoke tests before touching state so you detect regressions early.

  • Angular CLI/Framework 20, RxJS 7.8, TypeScript strict

  • Enable standalone/functional providers; keep NgModules temporarily

Commands I actually run

RxJS Breakers and Concrete Fixes

// 1) toPromise → firstValueFrom / lastValueFrom
// Before (Angular 12 code lurking in services)
async getUserBad() {
  return await this.http.get<User>('/api/user').toPromise();
}

// After
import { firstValueFrom } from 'rxjs';
async getUser() {
  return await firstValueFrom(this.http.get<User>('/api/user'));
}

// 2) shareReplay with refCount so cache doesn’t leak forever
const user$ = this.http.get<User>('/api/user').pipe(
  shareReplay({ bufferSize: 1, refCount: true })
);

// 3) Bridge to Signals for deterministic template access
const user = toSignal(user$, { initialValue: null as User | null });

// 4) WebSocket with backoff and proper teardown
const reconnect$ = new Subject<void>();
const socket$ = defer(() => new WebSocketSubject<Msg>('/ws')).pipe(
  retryBackoff({ initialInterval: 1000, maxRetries: 5 }),
  takeUntil(reconnect$)
);

// Signal for connection status used by a PrimeNG badge
const status = toSignal(socket$.pipe(map(() => 'online'), startWith('connecting')),
  { initialValue: 'connecting' as 'connecting'|'online'|'offline' }
);

Replace toPromise; tame shareReplay

Bridge streams to Signals deterministically

  • Prefer toSignal with initialValue for SSR

  • Use fromObservable only when composing downstream signals

Clean up hot streams and retries

  • Gate reconnects with exponential backoff

  • Tear down with takeUntilDestroyed() or takeUntil

Modernize NgRx Without a Rewrite

// Functional effect (NgRx 16+) keeps DI local and tests simple
export const loadUsers = createEffect(
  (
    actions$ = inject(Actions),
    api = inject(UsersApi)
  ) => actions$.pipe(
    ofType(usersPageOpened),
    switchMap(() => api.list().pipe(
      map(usersLoaded),
      catchError((err) => of(usersLoadFailed({ err })))
    ))
  ),
  { functional: true }
);

export const USERS_EFFECTS = [loadUsers];

// app.config.ts
providers: [
  provideStore(reducers),
  provideEffects(USERS_EFFECTS),
]

// Bridge a selector to a signal
const selectUsers = createSelector(selectUsersState, (s) => s.users);
readonly users = toSignal(this.store.select(selectUsers), { initialValue: [] as User[] });

// Signal-based input for leaf components (Angular 17+)
import { input, computed } from '@angular/core';
export class UserCardComponent {
  readonly user = input.required<User>();
  readonly fullName = computed(() => `${this.user().first} ${this.user().last}`);
}
// New slices with SignalStore (NgRx Signals)
import { signalStore, withState, withComputed, withMethods } from '@ngrx/signals';

interface UsersState { users: User[]; loading: boolean; }

export const UsersStore = signalStore(
  { providedIn: 'root' },
  withState<UsersState>({ users: [], loading: false }),
  withComputed(({ users }) => ({
    count: computed(() => users().length)
  })),
  withMethods((store) => ({
    load: () => {
      store.patchState({ loading: true });
      return inject(UsersApi).list().pipe(
        tap((users) => store.patchState({ users, loading: false })),
        catchError(() => {
          store.patchState({ loading: false });
          return EMPTY;
        })
      ).subscribe();
    }
  }))
);

Move effects to functional style

  • Eliminate constructor injection; use inject()

  • Provide effects with provideEffects() for composability

Adapt selectors to Signals; phase in SignalStore

  • toSignal on critical selectors; computed for derived state

  • Introduce SignalStore for new slices; keep reducers where stable

Router and HTTP go functional

  • Functional guards/resolvers; withInterceptors for HttpClient

Example: Telecom Analytics Upgrade—State, RxJS, and Change Detection

<!-- Template: replace async-pipe chatter with Signals -->
<section>
  <p-badge [value]="status()" severity="success"></p-badge>
  <p-table [value]="users()"></p-table>
</section>

Baseline issues (pre-upgrade)

We instrumented with Angular DevTools and GA4 timings. INP hovered ~260ms on data-heavy views; change detection cycles spiked under WebSocket bursts.

  • Effects created hot long-lived streams; memory growth > 300MB/day

  • shareReplay(1) without refCount pinned data across routes

  • SSR hydration flapped with constructor-time subscriptions

Interventions that moved the needle

Within two sprints, we cut change detection cycles by ~32%, memory plateaued, and INP stabilized around 170–190ms. Hydration errors dropped to zero after moving subscriptions out of constructors and into signal adapters.

  • Functional effects + toSignal on critical selectors

  • SignalStore for session/user slices; reducers left for reports

  • Coalesced change detection; explicit toSignal bridges for sockets

When to Hire an Angular Developer for Legacy Rescue

I’ve done this on employee tracking/payment systems (global entertainment), airport kiosk portals with Docker-based hardware simulation, a broadcast VPS scheduler, and insurance telematics dashboards. The thread: staged upgrades, explicit state, and measurable UX.

Bring in help if you see these signals (no pun intended)

If your team needs a seasoned Angular consultant to triage, I can join as a remote Angular developer and stabilize the codebase quickly—rescuing legacy patterns while keeping releases flowing. See how I “stabilize your Angular codebase” and “rescue chaotic code” via gitPlumbers.

  • Hydration flaps or flaky SSR tests after a version bump

  • INP/TTI regressions when WebSockets or polling starts

  • Selectors/effects difficult to test or reason about

  • CI can’t lock a consistent RxJS/Angular toolchain

Measurable Outcomes and What to Instrument Next

What to track

Tie CI to budgets. Fail PRs if INP regresses >10% or memory grows >15%. Use Firebase Logs or your APM to watch for effect retries and socket churn.

  • Angular DevTools: change detection cycles per interaction

  • Lighthouse/INP and memory snapshots per route

  • Custom telemetry for WebSocket reconnects and errors

Next steps

Once the core flows are on Signals, the rest is rinse-and-repeat. You’ll see steadier SSR, faster dashboards, and simpler tests.

  • Phase out legacy selectors for computed()

  • Adopt zoneless only when signals cover every UI path

  • Standardize rxMethod patterns in SignalStore

FAQs: Angular 20 Upgrades—Signals, RxJS, and State

Related Resources

Key takeaways

  • Inventory and fix RxJS anti‑patterns (toPromise, hot Observable leaks, shareReplay misconfig) before enabling Signals.
  • Migrate NgRx effects/selectors incrementally; bridge to Signals with toSignal/fromObservable and SignalStore.
  • Adopt functional providers (provideHttpClient, provideRouter) and signal-based inputs to reduce NgModule churn.
  • Coalesce change detection first; trial zoneless behind flags after Signals own the UI updates.
  • Measure with Angular DevTools + Lighthouse; expect fewer checks, lower memory, and more deterministic SSR/tests.

Implementation checklist

  • Pin toolchain and run ng update or Nx migrate stepwise to Angular 20.
  • Replace toPromise with firstValueFrom/lastValueFrom; audit shareReplay and Subjects.
  • Convert 10–20% of critical selectors to Signals using toSignal; gate with feature flags.
  • Move interceptors/guards to functional providers; introduce provideZoneChangeDetection({ eventCoalescing: true }).
  • Refactor brittle effects to functional createEffect; decouple from constructors with inject().
  • Introduce SignalStore in new slices; leave legacy reducers in place behind adapters.
  • Add Angular DevTools profiling and CI budget checks; verify SSR hydration determinism.

Questions we hear from teams

How long does an Angular 12–15 → 20 upgrade take?
For mid-size dashboards, 4–8 weeks is typical: 1 week assessment, 1–2 weeks RxJS/NgRx fixes, 1–2 weeks Signals adapters, and 1–2 weeks hardening/CI. Complex monorepos or kiosks with offline flows can stretch to 8–12 weeks.
What does an Angular consultant actually do on this upgrade?
Inventory state and RxJS risks, pin toolchain, convert critical selectors to Signals, modernize effects, and coalesce change detection. I also instrument Angular DevTools/Lighthouse budgets and set CI gates so regressions can’t merge.
Do we have to rewrite NgRx to use Signals?
No. Keep reducers; adapt selectors with toSignal, and introduce SignalStore for new slices. Over time, migrate complex derived selectors to computed(). This avoids a risky big-bang rewrite.
Will zoneless change detection break my app?
Go zoneless only after Signals own updates and you’ve audited third-party libraries. Start with provideZoneChangeDetection({ eventCoalescing: true }) and ship canaries behind flags before removing zone triggers.
How much does it cost to hire an Angular developer for this work?
Senior Angular consultant rates vary by scope and urgency. I offer assessments and fixed-scope upgrade tracks. Typical engagement: discovery within 48 hours; assessment in 1 week; implementation 3–6 weeks. Get a proposal tailored to your repo and CI.

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 Live Angular 20+ Apps (NG Wave, gitPlumbers, IntegrityLens, SageStepper)

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