
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=ciaffected: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
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.
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