
Modernize State to Signals in Legacy AngularJS/9–14 Apps Without a Rewrite: Adapters, SignalStore, and Strangler Patterns
A practical, low‑risk path to upgrade state management to Angular 20+ Signals—while legacy UI keeps shipping.
“You don’t need a rewrite to get Signals wins. Wrap what you have, move the hot paths, measure, and keep shipping.”Back to all posts
If your dashboard jitters when data spikes, you’re not alone. I’ve stabilized employee trackers at a global entertainment company and modernized airport kiosks for a major airline—often with Angular 9–14 code, sometimes AngularJS. Budgets didn’t allow rewrites, but leadership wanted Angular 20 Signals benefits now. Here’s the exact statecraft I use: adapters, SignalStore slices, and a strangler pattern that keeps production calm.
Why Jumping to Signals Without a Rewrite Matters Now
The 2025 reality: shipping beats rewriting
As companies plan 2025 Angular roadmaps, leadership wants tactile UX gains—faster dashboards, stable forms, fewer janky frames—without burning quarters on a rewrite. Signals give us deterministic state, fine-grained reactivity, and simpler mental models. We can graft Signals onto legacy AngularJS/9–14 code and start harvesting wins next sprint.
Budgets favor incremental wins
Angular 20+ Signals boost UX immediately
Rewrites stall delivery for quarters
Where Signals move the needle
On an ads analytics dashboard for a telecom provider, switching the hottest charts to Signals dropped component recalculations by ~38% and raised Lighthouse performance by 12 points—no visual changes, no rewrite. Similar patterns held on a telematics platform: smoother maps and fewer flame chart spikes in Angular DevTools.
Deterministic reactivity
Lower change detection pressure
Simpler local reasoning
The Safe Strangler Plan for Legacy State
Step 1: Inventory and classify state
Tag each state source by: read frequency, write complexity, and UX criticality. In Nx, I drop this as a markdown checklist beside the affected libraries so PRs stay scoped and reviewable.
Services with BehaviorSubject
NgRx feature stores/selectors
AngularJS $rootScope/$broadcast
WebSocket/event streams
Step 2: Introduce a Signals Facade (read-only first)
Create a facade that exposes Signals selectors wrapping the current store or services. Components swap to signals-based reads with zero action code changes. This alone trims rerenders and removes accidental async pipe cascades.
Use selectSignal()/toSignal()
Keep existing actions/effects
Don’t change reducers yet
Step 3: Carve critical slices into SignalStore
Move high-churn, UI-centric slices (filters, selection, ephemeral UI state) into SignalStore first. Preserve action/method names to reduce diff size. Cross-cutting or side-effect-heavy flows (auth, websockets) can remain in NgRx until we’re ready.
Start with hot paths
Keep NgRx for cross-cutting effects
Offer identical API shape
Step 4: Flip components incrementally
I enable toggles via Firebase Remote Config or an environment flag. We migrate the container, validate telemetry, then proceed to children. If KPIs regress, roll back by flipping a flag—no code reverts needed.
One container at a time
Add effect() for imperative syncing
Feature-flag the flip
Step 5: Prove it with metrics
I set budgets in CI and emit analytics on key state updates—e.g., how long a filter change takes to settle charts. In one case we cut interaction-to-next-paint from 220ms to 120ms simply by localizing derived state with computed().
Angular DevTools signals graph
Lighthouse + Core Web Vitals
Firebase Performance traces
Signals Adapters That Work with NgRx and Legacy Services
// facade/orders.facade.ts
import { Injectable, computed, inject } from '@angular/core';
import { Store, selectSignal } from '@ngrx/store';
import { toSignal } from '@angular/core/rxjs-interop';
import * as Orders from './orders.selectors';
import * as OrdersActions from './orders.actions';
import { OrdersService } from './orders.service';
@Injectable({ providedIn: 'root' })
export class OrdersFacade {
private store = inject(Store);
private svc = inject(OrdersService); // legacy Observable API
// NgRx → Signals
orders = selectSignal(this.store, Orders.selectAll);
loading = selectSignal(this.store, Orders.selectLoading);
// Observable service → Signal
svcHealth = toSignal(this.svc.health$(), { initialValue: 'unknown' });
// Derived with computed()
openOrders = computed(() => this.orders().filter(o => o.status === 'open'));
// Preserve dispatch API
refresh() { this.store.dispatch(OrdersActions.refresh()); }
}// component usage (Angular 20+ template)
@Component({
selector: 'orders-table',
template: `
<p-progressBar *ngIf="facade.loading()" mode="indeterminate"></p-progressBar>
<orders-grid [rows]="facade.openOrders()"></orders-grid>
`
})
export class OrdersTableComponent { constructor(public facade: OrdersFacade) {} }Facade wrapping NgRx selectors with Signals
This gives you Signals benefits with near-zero surface change. Components still dispatch actions; they just read state via signals.
Use selectSignal from NgRx 16+
Expose readonly signals
Keep dispatch API
From Observable services to Signals
Legacy BehaviorSubjects can be wrapped and gradually replaced. Keep the original service as the single writer while components consume Signals.
toSignal for Observables
computed for derived slices
effect for bridging writes
Introducing SignalStore Slices Without Breaking Features
// stores/filters.store.ts (NgRx SignalStore)
import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
interface FilterState {
query: string;
status: 'all' | 'open' | 'closed';
}
const initial: FilterState = { query: '', status: 'all' };
export const FiltersStore = signalStore(
{ providedIn: 'root' },
withState(initial),
withComputed(({ query, status }) => ({
hasQuery: () => query().length > 0,
isNarrow: () => status() !== 'all'
})),
withMethods((store) => ({
setQuery(query: string) { patchState(store, { query }); },
setStatus(status: FilterState['status']) { patchState(store, { status }); },
reset() { patchState(store, initial); }
}))
);// filters.facade.ts — preserving old API
@Injectable({ providedIn: 'root' })
export class FiltersFacade {
constructor(private filters: FiltersStore) {}
query = this.filters.query; // signal read
status = this.filters.status; // signal read
hasQuery = this.filters.hasQuery; // computed signal
setQuery(v: string) { this.filters.setQuery(v); }
setStatus(s: 'all' | 'open' | 'closed') { this.filters.setStatus(s); }
reset() { this.filters.reset(); }
}Why SignalStore here
I start with filter state, selections, and ephemeral UI. Keep NgRx for server mutations and cross-cutting concerns; migrate later once benefits are clear.
Local UI state is a perfect fit
Deterministic updates
Less boilerplate than reducers
Minimal API churn
If the old API was setFilter(value), keep it. Add internal analytics on mutation to track UX impact—hiring teams love that traceability.
Mirror old facade methods
Keep input/output shapes
Add analytics hooks
AngularJS Hybrids and Microfrontends: How to Bridge
// bridging: from Signal to Observable for AngularJS consumers
import { toObservable } from '@angular/core/rxjs-interop';
import { signal } from '@angular/core';
import { map, startWith } from 'rxjs/operators';
const count = signal(0);
export const count$ = toObservable(count).pipe(
map(v => ({ value: v })),
startWith({ value: 0 })
);
// AngularJS controller can $scope.$watch via async pipe or subscribe// downgrade an Angular component into AngularJS
import { downgradeComponent } from '@angular/upgrade/static';
import { OrdersTableComponent } from './orders-table.component';
angular.module('legacy').directive('ordersTable', downgradeComponent({
component: OrdersTableComponent
}));Hybrid with ngUpgrade
For an insurance telematics dashboard, we introduced Angular 20 widgets into an AngularJS shell. Signals powered the widget internals while AngularJS consumed Observables derived from signals—no watcher storms.
Downgrade Angular 20 islands
Bridge signals via toObservable
Keep AngularJS controllers stable
Microfrontend islands
When upgrade is not feasible, I ship self-contained Angular 20+ islands with Signals and a narrow customEvent API. We forward only typed events to the shell.
Expose a typed events contract
Use Web Components or Module Federation
Isolate state leaks
Instrumentation, Guardrails, and A11y Checks
# .github/workflows/ci.yml (excerpt)
name: ci
on: [pull_request]
jobs:
build-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npx nx affected -t lint,test,build --parallel=3
- run: npx lhci autorun --config=./lighthouse-ci.json
- run: npx cypress run --component --browser chromeCI budgets and telemetry
I wire Nx in GitHub Actions to run unit, Cypress E2E, Lighthouse, and axe-core on each flagged flip. Bundle budgets prevent regressions. We emit analytics when signals-based filters or grids update to verify latency targets.
Nx + GitHub Actions
Lighthouse CI and a11y budgets
GA4 + Firebase Performance
Angular DevTools for Signals
DevTools is invaluable here—watch derived selectors for thrash. On an airport kiosk sim (Docker-based hardware), we caught a barcode-scan effect causing needless recompute; a small memoization fix stabilized the flow.
Track dependency graphs
Spot accidental recomputes
Validate effect boundaries
How an Angular Consultant Approaches Signals Migration
Typical engagement timeline
I start with a one-week assessment: flame charts, DevTools traces, NgRx map, and a Signals PoC in a safe feature flag. Then we migrate the top 1–2 hot paths (filters/tables/charts). Finally, we scale slices, add CI guardrails, and document contracts.
Week 1: assessment + PoC
Weeks 2–3: hot-path migration
Weeks 4–6: expand + harden
Deliverables you can bank on
You’ll get a strangler plan, adapter libraries in Nx, stable SignalStore slices, feature flags, and dashboards that prove improvements. When you hire an Angular developer, this is the accountability you want.
Adapter facades + SignalStore slices
Roll-forward/rollback flags
Telemetry dashboards
When to Hire an Angular Developer for Legacy Rescue
Good signals you should bring help
If your team is stuck between rewrite-or-bust and a noisy backlog, bring in an Angular expert who has shipped this exact path. I’ve moved AngularJS/9–14 systems to Signals while keeping production stable and stakeholders happy.
Janky dashboards under load
NgRx complexity blocking features
AngularJS shell with critical deadlines
What success looks like
On IntegrityLens and SageStepper I apply the same guardrails. gitPlumbers maintains 99.98% uptime across continuous modernizations. The playbook scales across domains: analytics, kiosks, telematics, and accounting dashboards.
20–40% fewer recomputes on hot views
+10–20 Lighthouse points on key pages
Feature velocity maintained
Key takeaways
- You can adopt Angular 20+ Signals on legacy AngularJS/9–14 apps without a full rewrite using adapters and facades.
- Start by wrapping existing Observables/NgRx into Signals, then move hot paths and shared state to SignalStore slices.
- Use a strangler pattern: component-by-component swaps, feature flags, and CI guardrails to avoid regressions.
- Measure impact with Angular DevTools, Lighthouse, Firebase Performance, and UX telemetry events.
- This approach works for hybrid ngUpgrade setups and microfrontends; keep shipping while you modernize.
Implementation checklist
- Inventory state sources: services, BehaviorSubjects, NgRx stores, and AngularJS $rootScope events.
- Introduce a Signals Facade per domain: wrap select() with selectSignal/toSignal.
- Add SignalStore for new/critical slices; keep NgRx for complex effects until parity.
- Flip components incrementally: async pipe → signal reads; add effect() for imperative reactions.
- Guard with feature flags and CI: a11y, Lighthouse budgets, bundle sizes, and smoke E2Es.
- Instrument Core Web Vitals and change detection counts; verify improvements before expanding scope.
Questions we hear from teams
- Do we need to rewrite NgRx if we move to Signals?
- No. Start by exposing NgRx selectors as Signals with selectSignal. Migrate UI-centric slices to SignalStore where it reduces boilerplate. Keep NgRx for effects-heavy, cross-cutting flows until parity is clear.
- How long does a Signals modernization take?
- A targeted rescue is 2–4 weeks for assessment and hot-path migration, 4–8 weeks to expand slices and harden CI/telemetry. Scope depends on feature count, NgRx complexity, and whether an AngularJS shell is involved.
- What does an Angular consultant deliver on this engagement?
- A strangler migration plan, adapter facades, SignalStore slices, feature flags for rollbacks, and telemetry dashboards proving UX gains. Expect docs, tests, and guardrails so your team can continue confidently.
- How much does it cost to hire an Angular developer for this work?
- Costs vary by scope and timelines. Typical rescue pilots start as fixed-fee assessments, then move to weekly engagement. Book a discovery call to align on hot paths and KPIs before we price options.
- Will this break production?
- The process is flag-driven and incremental. We flip components to Signals behind feature flags, verify with CI and telemetry, and roll back instantly if needed. No big-bang deployments required.
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