Rescue Legacy AngularJS/9–14 State Without a Rewrite: Signals Adapters, Facades, and a Strangler Path That Ships

Rescue Legacy AngularJS/9–14 State Without a Rewrite: Signals Adapters, Facades, and a Strangler Path That Ships

Modernize state to Angular 20+ Signals and SignalStore while features keep shipping—no big-bang rewrite required.

Don’t rewrite—strangle. Wrap legacy state with Signals today, replace slices with SignalStore tomorrow, and keep shipping the whole time.
Back to all posts

The Jittery Dashboard Moment

What I see in the field

I’m Matthew Charlton. I’ve rescued AngularJS and Angular 9–14 apps in aviation, media, telecom, insurance, IoT, and fintech. The pattern is familiar: a KPI dashboard jitters, a modal opens 300ms late, memory creeps after every route change. Teams want Signals but can’t stop shipping features. Good news: you don’t need a rewrite. You need a Signals adapter layer, a facade, and a strangler plan that you can defend in CI.

  • Runaway change detection on every keystroke

  • Subscriptions orphaned in feature flags

  • Selectors feeding components and pipes directly

  • Homegrown stores + NgRx + Subjects all in one app

Why Angular 12 Apps Break During Signals Migration

Risk areas to avoid

Signals shift your mental model from streams to pull-based computation. If you rip out NgRx or legacy subjects too quickly, you’ll break effect chains, selectors, or memoization. The workable path is incremental: wrap legacy in read-only signals, move mutations behind a facade, then replace slices with SignalStore when tests and telemetry say it’s safe.

  • Eagerly replacing NgRx with SignalStore across the app

  • Mixing mutable shared services with computed signals

  • Tight coupling to zone.js-based side effects

  • Skipping telemetry, so regressions hide until prod

The Incremental Plan: Adapters, Facades, and a Strangler Fig

// Step 2–3: Signals facade over legacy NgRx selectors and subjects
import { Injectable, computed, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { selectUsers, selectSelectedUserId } from './users.selectors';
import * as UsersActions from './users.actions';

@Injectable({ providedIn: 'root' })
export class UsersFacade {
  private store = inject(Store);

  // Read-only signals wrapping existing selectors
  readonly users = toSignal(this.store.select(selectUsers), { initialValue: [] as ReadonlyArray<User> });
  readonly selectedUserId = toSignal(this.store.select(selectSelectedUserId), { initialValue: null as string | null });

  // Derived state with computed
  readonly selectedUser = computed(() => this.users().find(u => u.id === this.selectedUserId()) ?? null);

  // Write methods keep legacy actions intact for now
  selectUser(id: string) {
    this.store.dispatch(UsersActions.selectUser({ id }));
  }

  loadUsers() {
    this.store.dispatch(UsersActions.loadUsers());
  }
}

// Step 4: Replace one slice with @ngrx/signals SignalStore
import { signalStore, withState, withMethods, patchState, withComputed } from '@ngrx/signals';
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';

interface UsersState {
  users: ReadonlyArray<User>;
  loading: boolean;
  error?: string;
  selectedUserId?: string | null;
}

const initialState: UsersState = { users: [], loading: false, selectedUserId: null };

export const UsersStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  withComputed(({ users, selectedUserId }) => ({
    selectedUser: () => users().find(u => u.id === selectedUserId()) ?? null,
    hasError: () => !!users() && !!selectedUserId(),
  })),
  withMethods((state) => {
    const http = inject(HttpClient);
    return {
      async loadUsers() {
        patchState(state, { loading: true, error: undefined });
        try {
          const users = await http.get<User[]>('/api/users').toPromise();
          patchState(state, { users: users ?? [], loading: false });
        } catch (e: any) {
          patchState(state, { loading: false, error: e?.message ?? 'Load failed' });
        }
      },
      selectUser(id: string | null) { patchState(state, { selectedUserId: id }); }
    };
  })
);

<!-- Step 3–4: Component stays simple, Signals do the heavy lifting -->
<section *ngIf="usersStore.loading(); else ready">
  <p-progressSpinner></p-progressSpinner>
</section>
<ng-template #ready>
  <p-table [value]="usersStore.users()" selectionMode="single" [(selection)]="sel" (onRowSelect)="usersStore.selectUser(sel?.id)">
    <ng-template pTemplate="header">
      <tr><th>Name</th><th>Role</th></tr>
    </ng-template>
    <ng-template pTemplate="body" let-user>
      <tr><td>{{ user.name }}</td><td>{{ user.role }}</td></tr>
    </ng-template>
  </p-table>
</ng-template>

Step 1: Baseline and budget your wins

Before touching code, capture baselines. On a telecom ads analytics dashboard, we cut interactive latency by 28% just by stopping wasteful zone churn with Signals. If you need to hire an Angular developer to run a fast assessment, I can deliver a state audit within a week.

  • Angular DevTools: profile change detection and component updates

  • Core Web Vitals: INP and LCP in Lighthouse CI

  • Memory: Chrome heap snapshots on route churn

  • Business KPIs: task success and time-to-insight

Step 2: Wrap legacy selectors and subjects in read-only Signals

This step delivers immediate wins with almost zero risk. Components consume signals; legacy pipelines keep working behind the scenes.

  • Keep NgRx selectors and service subjects; expose toSignal wrappers

  • No mutations yet—preserve existing dispatch/next calls

  • Use computed() for derived state to simplify components

Step 3: Introduce a Signals Facade

The facade is your seam. It hides whether the source is NgRx, a Subject, Firebase, or a SignalStore slice.

  • Centralize reads (signals) and writes (methods)

  • Guard with feature flags and contract tests

  • Route mutations to legacy actions/effects until replaced

Step 4: Strangler-replace one slice with SignalStore

Ship one slice at a time: users, sessions, preferences, or a single report. In a broadcast network VPS scheduler, we moved just the calendar slice to SignalStore in two weeks while features kept shipping.

  • Clone state shape; keep action names for telemetry continuity

  • Migrate effects to methods using inject(HttpClient)

  • Delete legacy slice when dashboards and tests go green

Step 5: CI and telemetry guardrails

Guard the migration. When we stabilized an employee tracking and payment system, CI caught a regression where a computed signal looped a date pipe; we fixed it before prod.

  • Nx affected targets + Cypress canaries

  • Lighthouse CI budgets for INP/LCP

  • OpenTelemetry/GA4 events for state transitions

  • Firebase Logs for error taxonomy

Bridging AngularJS or Mixed Services to Signals

If you still have AngularJS pieces

You don’t need to drag AngularJS into your Angular 20 app. Keep it isolated and surface the minimum contract as signals.

  • Expose $rootScope events as RxJS and wrap with toSignal

  • Use an upgrade adapter at the boundary, not inside components

  • Prefer read-only signals until you strangle the service

Example: $rootScope to signal

// AngularJS -> RxJS -> Angular Signal
import { toSignal } from '@angular/core/rxjs-interop';
import { Observable } from 'rxjs';

function fromRootScope($rootScope: angular.IRootScopeService, event: string): Observable<any> {
  return new Observable(obs => {
    const off = $rootScope.$on(event, (_, data) => obs.next(data));
    return () => off();
  });
}

export function rootScopeEventSignal($rootScope: angular.IRootScopeService, event: string) {
  return toSignal(fromRootScope($rootScope, event), { initialValue: null });
}

How an Angular Consultant Approaches Signals Migration

My 6-step engagement

In an airline kiosk project, we used Docker-based hardware simulation and offline-tolerant flows. The same adapter/facade strategy let us modernize device state (printers, scanners, card readers) to Signals without blocking field deployments.

  • Discovery (48 hours): repo scan, dependency map, risk register

  • Assessment (1 week): state topology, event schema, rollout plan

  • Pilot (2 weeks): signals facade + 1 SignalStore slice

  • Expand (2–6 weeks): module-by-module strangler replacement

  • Harden: telemetry hooks, error taxonomy, CI budgets

  • Knowledge transfer: patterns, playbooks, and guardrails

When to Hire an Angular Developer for Legacy Rescue

Signals you need help now

If this reads like your week, hire an Angular expert who has shipped this path before. I bring Nx monorepo discipline, PrimeNG/Material theming, WebSocket telemetry patterns, and Firebase-backed canaries that make risky changes safe.

  • Critical paths cross NgRx, subjects, and AngularJS services

  • Feature velocity is high but regressions keep slipping into prod

  • Core Web Vitals (INP) and memory trends worsen after releases

  • You need a zero-downtime plan while upgrading Angular versions

Production Safety: Telemetry, CI, and Contract Tests

Contract test a slice before and after

Typed events let you compare behavior across the strangler boundary. On IntegrityLens (our AI-powered verification system), we used the same tactic to validate streaming state—MTTR dropped by 35%.

  • Define typed event schemas for state transitions

  • Assert same outputs for same inputs pre/post migration

  • Log to Firebase with correlation IDs for MTTR cuts

Nx + GitHub Actions

name: ci
on: [push, pull_request]
jobs:
  build-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
        with: { version: 9 }
      - run: pnpm install --frozen-lockfile
      - run: pnpm nx affected -t lint,test,build --parallel=3
      - run: pnpm nx run web:ci-lh # Lighthouse budgets for INP/LCP
      - run: pnpm nx run e2e:cy:run # Cypress canaries

Instrument with Angular DevTools and GA4

Ship with numbers. In a telecom analytics platform, this discipline let us keep a 99.98% uptime while modernizing state, similar to how gitPlumbers helps teams stabilize and rescue chaotic codebases.

  • Count component updates before/after

  • Track interaction-to-next-paint (INP) by route

  • Graph state errors by taxonomy in Firebase Logs

Mini Case Study: From Dashboard Jitters to SignalStore

Context and constraints

We started by wrapping selectors in signals and moved the heaviest charting slice (D3/Highcharts) into SignalStore. Data virtualization and typed WebSocket events fed signals directly; components dropped 30–50% of change detection cycles, and INP improved from 220ms to 130ms across key dashboards.

  • Angular 12, NgRx + ad-hoc subjects, PrimeNG tables/charts

  • Release train every two weeks; no feature freeze allowed

  • Multiple tenants with role-based views

Results you can take to leadership

This is the kind of outcome that makes budget conversations easier as companies plan 2025 Angular roadmaps.

  • -40% component updates on hot routes

  • -90% subscription code in components

  • +25% faster perceived interactions (INP)

  • No missed releases; zero-downtime rollout

Practical Code Patterns You Can Drop In Today

A thin signals adapter for any observable

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

export function asROSignal<T>(source$: Observable<T>, initialValue: T): Signal<T> {
  return toSignal(source$, { initialValue });
}

Guarded feature flag around the strangled slice

// env flag or Firebase Remote Config
@if (env.useSignalStoreUsers) {
  usersStore.loadUsers();
} @else {
  usersFacade.loadUsers();
}

Error taxonomy hook

export type UserError =
  | { kind: 'network'; path: string; status: number }
  | { kind: 'parse'; path: string; message: string }
  | { kind: 'auth'; message: string };

function logUserError(e: UserError) {
  // send to Firebase Logs / OpenTelemetry
}

Final Checklist and What to Instrument Next

Ship this week

Measure before and after. If the numbers don’t move, we adjust—no rewrites, just surgical improvements.

  • Wrap 2–3 selectors/subjects with signals in your hottest route

  • Add a facade with read methods + write methods (legacy under the hood)

  • Pick one slice to strangle with SignalStore; guard with a flag

  • Add INP/LCP budgets and a Cypress canary test in Nx CI

Instrument next

For real-time telemetry pipelines, I use typed event schemas, exponential retry, and backoff strategies with graceful UI states—these patterns carried our insurance telematics dashboards to production without drama.

  • Typed WebSocket events for real-time dashboards

  • GA4 engagement time per route after Signals adoption

  • Error taxonomy heatmap in Firebase Logs

Related Resources

Key takeaways

  • You can modernize state to Signals without pausing feature work using adapters and facades.
  • Start with read-only Signals over existing NgRx/subjects, then strangler‑replace slices with SignalStore.
  • Guard the migration with Nx CI, contract tests, and production telemetry to avoid regressions.
  • Measure wins: fewer change detection runs, lower INP, less memory churn, simpler components.
  • Hire an Angular expert when critical flows span multiple legacy patterns or timelines are tight.

Implementation checklist

  • Baseline metrics: Angular DevTools timings, INP/LCP, memory snapshots.
  • Introduce a Signals facade over existing store/selectors/services.
  • Migrate one slice with @ngrx/signals SignalStore; keep actions compatible.
  • Instrument state transitions (OpenTelemetry/GA4, Firebase Logs).
  • Add contract tests for events/state; protect with Nx CI + Cypress canaries.
  • Repeat per feature module; remove strangled legacy bits behind flags.

Questions we hear from teams

How much does it cost to hire an Angular developer for a Signals migration?
It depends on scope, but typical discovery + pilot runs 2–4 weeks. I offer fixed‑fee assessments and sprint‑based delivery. You get a plan, a pilot slice in SignalStore, CI guardrails, and metrics leaders can trust.
How long does a legacy Angular 9–14 to Signals migration take?
A pilot slice lands in 2 weeks. Full migrations roll out module by module over 4–12 weeks, depending on complexity, testing maturity, and release cadence.
Do we need to replace NgRx completely?
No. Many teams keep NgRx for some flows and use SignalStore where it simplifies code. The facade lets you mix safely and move at your own pace.
Will this break production or require a feature freeze?
No. We run a strangler plan with feature flags, Nx CI, Cypress canaries, and telemetry. Rollouts are incremental and reversible.
What’s included in a typical engagement?
Repo assessment, state topology map, event schema, Signals facade, one SignalStore slice, CI/telemetry hardening, and knowledge transfer. Discovery call within 48 hours; assessment delivered within one week.

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 code rescue results at gitPlumbers (70% velocity increase)

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