Upgrade Angular 12–15 to Angular 20 Without Breakage: State Management, RxJS, and Change Detection

Upgrade Angular 12–15 to Angular 20 Without Breakage: State Management, RxJS, and Change Detection

A field-tested migration plan using Signals, NgRx SignalStore, and RxJS interop—upgrade to Angular 20+ without freezing delivery or spiking render counts.

“Upgrade the framework first, then make Signals pay for themselves on the hot paths. Measure every step.”
Back to all posts

Scene from the front lines: why your 12–15 dashboard jitters on ng20

What I see in real upgrades

I’ve upgraded employee tracking systems, airline kiosks, and telecom analytics dashboards through multiple Angular majors. The dangerous part isn’t ng update—it’s the ripple effects in state, RxJS, and change detection. On a telecom analytics app we moved from Angular 11 to 20 with Signals and cut INP 68%, but only after fixing three classes of breakage: NgRx selector churn, RxJS cache misuse, and zone-triggered renders. If you need an Angular consultant who’s done this at Fortune 100 scale, this guide is the exact playbook I use.

  • Dashboard cards double-render after route changes

  • Selectors fire too often and async pipes redraw charts

  • RxJS shareReplay caches don’t refCount and leak

  • Forms lose types post-upgrade and break reducers

Why this migration matters for 20+

2025 reality for Angular teams

As companies plan 2025 Angular roadmaps, the 12–15 to 20 jump is where you pay down technical debt and align with Signals-first patterns. Done right, you’ll get fewer renders, faster dashboards, and simpler state. Done wrong, you’ll ship more code and slower UX.

  • Signals are first-class; standalone APIs are table stakes

  • NgRx offers SignalStore and signal interop

  • SSR/hydration and tighter TypeScript rules surface hidden bugs

Step-by-step: 12–15 to 20 without pausing delivery

# 1) Create a safe branch
git checkout -b feat/upgrade-ng20

# 2) Framework + tooling (Nx-aware)
npx nx migrate @angular/cli@20 @angular/core@20
npx nx migrate @ngrx/store@latest @ngrx/effects@latest @ngrx/entity@latest
pnpm install
npx nx migrate --run-migrations

# 3) Lock CI toolchain (example GitHub Actions)

# .github/workflows/ci.yml (snippet)
jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node: [20]
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with: { version: 9 }
      - uses: actions/setup-node@v4
        with: { node-version: ${{ matrix.node }}, cache: 'pnpm' }
      - run: pnpm install --frozen-lockfile
      - run: pnpm nx run-many -t build,test,lint -c ci

1) Create the runway

I use Nx to stage upgrades and keep features shipping. Each slice has a flag to flip at runtime.

  • Branch per slice (shell, shared libs, feature areas)

  • Pin Node/PNPM/TS in CI to avoid drift

  • Run migrations locally and in CI the same way

2) Run framework + workspace migrations

Prefer Nx migrations so you can review changes. Keep strict mode on to flush risky types before Signals work.

  • Angular CLI/Core to 20

  • NgRx to latest

  • TypeScript to a supported version with strict on

Commands I actually run

State management: breaking changes and a safe path with Signals

// user.selectors.ts (legacy stays intact)
export const selectUsers = createSelector(selectUserState, s => s.list);
export const selectActiveTeamId = createSelector(selectTeamState, s => s.activeId);

// users.component.ts (Angular 20)
import { toSignal, computed, effect, signal, input } from '@angular/core';
import { Store } from '@ngrx/store';
import { selectUsers, selectActiveTeamId } from '../state';

@Component({
  selector: 'ux-users',
  templateUrl: './users.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true
})
export class UsersComponent {
  // reactive input using signals
  filters = input<{ q?: string }>();

  private users$ = this.store.select(selectUsers);
  private teamId$ = this.store.select(selectActiveTeamId);

  users = toSignal(this.users$, { initialValue: [] });
  teamId = toSignal(this.teamId$, { initialValue: null });

  // derived state as computed
  visible = computed(() => {
    const q = this.filters()?.q?.toLowerCase() ?? '';
    return this.users().filter(u => u.teamId === this.teamId() && u.name.toLowerCase().includes(q));
  });

  constructor(private store: Store) {
    // optional side-effect for analytics
    effect(() => {
      track('users_visible_count', { count: this.visible().length }); // GA4/Firebase
    });
  }
}

// users.store.ts (NgRx SignalStore composing server + selection)
import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';

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

const initialState: UsersState = { list: [], loading: false };

export const UsersStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  withComputed(({ list }) => ({
    total: computed(() => list().length),
  })),
  withMethods((store) => ({
    setLoading(loading: boolean) { patchState(store, { loading }); },
    setUsers(list: User[]) { patchState(store, { list }); }
  }))
);

// Feature components can consume UsersStore signals alongside legacy NgRx selectors.

Migrate NgRx incrementally (don’t rewrite)

Your existing store stays; Signals ride alongside. This removes risk and lets you measure ROI per component.

  • Upgrade NgRx to latest and keep reducers/selectors

  • Adopt createFeature/createActionGroup if you’re still on v12 APIs

  • Introduce SignalStore for new features while you stabilize legacy

Bridge selectors to Signals

This trims change detection work without tearing out NgRx.

  • Use toSignal on hot selectors

  • Memoize derived computed signals in components

  • Prefer signal inputs over @Input() + setters

Code: selector to Signal + computed

Code: a small SignalStore that composes NgRx data

RxJS migration landmines to fix in CI

// BEFORE (old signatures & stale cache risk)
const data$ = http.get<Item[]>(url).pipe(
  switchMap(items => combineLatest(items.map(i => loadDetails$(i.id, (a, b) => ({ a, b }))))), // resultSelector misuse
  shareReplay(1) // no refCount, stays subscribed forever
);

// AFTER
const data$ = http.get<Item[]>(url).pipe(
  switchMap(items => combineLatest(items.map(i => loadDetails$(i.id)))),
  map(detailsArr => detailsArr.map(d => ({ a: d.a, b: d.b }))),
  shareReplay({ bufferSize: 1, refCount: true })
);

// toPromise removal
async function loadOnce() {
  const v = await firstValueFrom(data$);
  return v;
}

What breaks moving from older code to modern RxJS

Most Angular 20 projects stick with RxJS 7.8.x; some pilots use RxJS 8. Either way, clean these up in CI before Signals work, or you’ll misattribute performance issues.

  • toPromise was removed; use firstValueFrom/lastValueFrom

  • Deprecated resultSelector overloads are gone

  • shareReplay(1) without refCount can leak and stale-cache

  • Type inference changes reveal bad any usage

Fixes I apply repeatedly

Change detection: cut renders with Signals and OnPush

<!-- BEFORE -->
<div *ngFor="let u of users$ | async">{{ u.name }}</div>

<!-- AFTER: bridge once, compute locally -->
<div *ngFor="let u of visible()">{{ u.name }}</div>

// App shell: ensure OnPush everywhere and avoid manual detectChanges
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true,
  selector: 'app-shell',
  template: `<router-outlet />`
})
export class AppShell {}

Practical targets

In an airport kiosk project, Signals + input() let us isolate device status updates (printers, barcode scanners) without repainting the whole view. Same trick works on dashboards built with PrimeNG or Angular Material.

  • High-churn dashboards (charts, tables, counters)

  • Large forms where validation thrashes

  • Kiosk screens with device polling

Template changes that pay off

Measure it

At AngularUX I push flame charts and render counts into CI to block regressions. That’s how we keep gitPlumbers at 99.98% uptime across modernizations.

  • Angular DevTools: Profiler -> record and count renders

  • Core Web Vitals: watch INP and LCP movements

  • GA4/BigQuery: custom event signals_render_count by route

When to Hire an Angular Developer for Legacy Rescue

Good moments to bring in help

If you need a remote Angular developer to steady the codebase, I can audit state/RxJS/change detection and ship a plan within a week. We’ll protect delivery while upgrading. See how we rescue chaotic code at gitPlumbers (70% velocity lift).

  • Missed SLAs due to upgrade blockers or flaky tests

  • Leadership wants Signals ROI with proof, not vibes

  • You need a slice-by-slice plan that won’t pause feature work

End-to-end example: upgrading a dashboard slice

// charts/traffic-card.component.ts
@Component({
  selector: 'ux-traffic-card',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <ux-chart [data]="series()" [loading]="loading()"></ux-chart>
  `
})
export class TrafficCardComponent {
  private traffic$ = this.store.select(selectTrafficByRegion);
  private loading$ = this.store.select(selectTrafficLoading);

  traffic = toSignal(this.traffic$, { initialValue: [] });
  loading = toSignal(this.loading$, { initialValue: false });

  series = computed(() => transformToHighcharts(this.traffic()));
  constructor(private store: Store) {}
}

What we change first

In a telecom analytics dashboard, this cut renders on critical cards by 86% and stabilized WebSocket updates.

  • Module -> standalone for the slice

  • Wrap three hot selectors with toSignal

  • Replace two async pipes feeding Highcharts with computed signals

Code sketch

What to instrument next to prove the upgrade

Telemetry hooks

Executives don’t want “it feels faster.” Show render counts dropping, INP improving, and selector churn reduced. Attach the numbers to PRs.

  • Log render_count, change_detection_cycles, and selector_hits

  • Attach commit SHAs and feature flags to GA4 events

  • BigQuery dashboard comparing pre/post per route

FAQs for upgrades and engagements

Related Resources

Key takeaways

  • Treat 12–15 to 20 as two tracks: framework/tooling upgrades first, then Signals adoption behind flags.
  • Bridge Observables to Signals with toSignal and NgRx SignalStore before deleting NgRx or async pipes.
  • Fix RxJS landmines (resultSelector removal, shareReplay config, toPromise removal) early in CI.
  • Cut render counts by moving high-churn streams to Signals + computed and using input() for reactive inputs.
  • Prove the win: track render counts and INP/LCP in CI with Angular DevTools traces and GA4 events.
  • Use feature flags and Nx projects to ship the upgrade in slices without pausing delivery.

Implementation checklist

  • Create a migration branch and run Angular/Nx migrations; lock Node/TS versions in CI.
  • Upgrade NgRx to latest; introduce SignalStore for new features—don’t rewrite everything.
  • Wrap legacy selectors with toSignal; replace only hot paths/components first.
  • Remove deprecated RxJS resultSelector overloads; configure shareReplay with refCount.
  • Switch components to ChangeDetectionStrategy.OnPush and adopt signal inputs.
  • Instrument flame charts and render count metrics; compare before/after in PRs.

Questions we hear from teams

How long does a typical Angular 12–15 to 20 upgrade take?
For a medium dashboard, 4–8 weeks end-to-end: 1 week assessment, 1–2 weeks framework/NgRx/RxJS cleanup, then 2–4 weeks Signals adoption on hot paths with CI guardrails. We keep features shipping with flags.
Do we need to replace NgRx with Signals?
No. Keep NgRx for global state and adopt Signals/SignalStore where it simplifies components. Bridge with toSignal and migrate selectively. Many teams run NgRx selectors and SignalStore side-by-side successfully.
What breaks most often with RxJS during upgrades?
Deprecated resultSelector overloads, toPromise removal, and shareReplay misconfiguration. Fix with map, firstValueFrom/lastValueFrom, and shareReplay({ bufferSize: 1, refCount: true }). Tighten TypeScript to catch hidden issues.
What does an Angular consultant actually deliver here?
A migration plan, PR-ready changes, CI upgrades, and instrumentation showing render count, INP/LCP, and selector churn improvement. I also coach your team to adopt Signals safely and document patterns recruiters can inspect.
How much does it cost to hire an Angular developer for this upgrade?
Scoped upgrades with telemetry typically start at a few weeks of contracting. I offer fixed-scope assessments and milestone-based delivery. Discovery call in 48 hours; assessment within a week; then sprint-based execution.

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 code (gitPlumbers)

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