
Plan a Multi‑Phase Migration from zone.js to Signals + SignalStore Without Breaking UX (Angular 20+)
A practical, low‑risk playbook to move from zone.js change detection to Signals + SignalStore with measurable guardrails—no jitter, no regressions.
Turning off zones is a reward for good state—not a starting point.Back to all posts
I’ve shipped dashboards for a global entertainment company, kiosks for a major airline, and analytics for a leading telecom provider. The fastest way to break UX is flipping zone.js off before your state is signal‑ready. This article lays out a phased plan I use on AngularUX and client work to migrate to Signals + SignalStore without regressions.
You’ll see the guardrails (Angular DevTools, flame charts, Lighthouse, Firebase), the code (SignalStore, RxJS interop), and the rollout steps. If you need a senior Angular engineer to run this migration, I’m available for remote engagements.
The Dashboard That Jitters: The Hook
If your Angular dashboard jitters during WebSocket bursts or your kiosk UI spins CPU during idle input listeners, you’re paying the zone.js tax. I’ve seen it at enterprise scale—a global entertainment company workforce tools, a broadcast media network scheduling, and United airport kiosks. The fix isn’t a flag; it’s a playbook.
As companies plan 2025 Angular roadmaps, Signals are table stakes. But turning off zones too early can cascade regressions—stale templates, phantom toasts, stuck spinners. Here’s the multi‑phase plan I run as an Angular consultant to modernize change detection with zero UX breakage.
Why Angular Teams Need a Phased zone.js → Signals Plan
What breaks when you rush
The result is flicker, missed updates, and support tickets. You don’t want to discover this on a Friday release.
Global change detection tied to async tasks disappears.
Third‑party libs that relied on zones stop updating templates.
Hidden coupling to two‑way bindings/imperative services gets exposed.
What you gain with Signals + SignalStore
For real‑time dashboards (ads analytics at a leading telecom provider, telematics at an insurance technology company), Signals reduce unnecessary reflows and keep components honest about their dependencies.
Deterministic updates, fewer renders, simpler tests.
Predictable domain slices with computed selectors.
Better SSR determinism and performance envelopes.
The Multi‑Phase Migration Plan
Phase 0 — Baseline and Guardrails
Before touching change detection, lock in a baseline. Use Angular DevTools to record component render counts on hot routes. Add Firebase Performance and GA4 custom metrics. If you can’t measure it, you can’t ship it safely.
Instrument render counts and flame charts.
Freeze UX: capture AA, Lighthouse, and error budgets.
Add CI checks in Nx to block regressions.
Phase 1 — Signals at the Leaf Level (keep zone.js)
Leaf components give maximum benefit with minimal blast radius. Templates become signal‑first, but the rest of your app still runs fine under zones.
Convert hot leaf components to signal inputs/computed.
Replace ad‑hoc Subjects with toSignal() at the edges.
Keep existing services/NgRx stable for now.
Phase 2 — Introduce SignalStore Slices
This creates deterministic state for key features without rewriting every stream. It also surfaces accidental coupling across services.
Model a domain slice (e.g., sessions, devices) with SignalStore.
Expose computed selectors and narrow, typed methods.
Bridge existing RxJS sources with toSignal().
Phase 3 — Third‑Party Boundary Adapters
Most PrimeNG components work fine, but anything that imperatively mutates DOM without outputs should be wrapped. Small adapters here avoid app‑wide hacks.
Wrap PrimeNG/third‑party widgets where needed.
Manually signal updates instead of relying on zones.
Use ChangeDetectionStrategy.OnPush to constrain re-renders.
Phase 4 — Zoneless Pilot, Then Rollout
Angular 20+ supports zoneless change detection; treat it like any other runtime behavior change. Start small, validate third‑party boundaries, then scale.
Pilot zoneless change detection on a low‑traffic route.
Gate with Firebase Remote Config and a server‑side kill switch.
Roll out progressively once metrics stay flat for 2+ weeks.
Implementation Details: Code and Guardrails
// rxjs-interop.ts
import { inject, Injectable, computed, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { WebSocketSubject } from 'rxjs/webSocket';
import { map, retry, shareReplay } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class FlightsFeed {
private ws = new WebSocketSubject<any>('wss://example.com/flights');
private flights$ = this.ws.pipe(
retry({ count: 5, delay: (e, i) => Math.min(1000 * 2 ** i, 15000) }),
map(msg => msg.payload as Flight[]),
shareReplay({ bufferSize: 1, refCount: true })
);
// Keep stream, expose a signal for templates/SignalStore
flights = toSignal(this.flights$, { initialValue: [] as Flight[] });
count = computed(() => this.flights().length);
}// flights.store.ts (SignalStore)
import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
import { inject, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
interface FlightsState {
flights: Flight[];
loading: boolean;
selectedId: string | null;
}
const initialState: FlightsState = { flights: [], loading: false, selectedId: null };
export const FlightsStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withComputed(({ flights, selectedId }) => ({
selected: computed(() => flights().find(f => f.id === selectedId()) ?? null),
delayedCount: computed(() => flights().filter(f => f.status === 'delayed').length),
})),
withMethods((store) => {
const http = inject(HttpClient);
return {
load: async () => {
patchState(store, { loading: true });
try {
const flights = await http.get<Flight[]>('/api/flights').toPromise();
patchState(store, { flights: flights ?? [], loading: false });
} catch {
patchState(store, { loading: false });
}
},
select: (id: string | null) => patchState(store, { selectedId: id })
};
})
);<!-- flights.component.html -->
<p-dropdown
[options]="store.flights()"
optionLabel="number"
[loading]="store.loading()"
(onChange)="store.select($event.value?.id)">
</p-dropdown>
<div *ngIf="store.selected() as sel">
<h3>Flight {{ sel.number }}</h3>
<p>Status: {{ sel.status }}</p>
</div>
<p>Delayed flights: {{ store.delayedCount() }}</p>// render-count.directive.ts — measure template stability
import { Directive, ElementRef, inject } from '@angular/core';
import { afterRender } from '@angular/core';
@Directive({ selector: '[renderCount]' })
export class RenderCountDirective {
private el = inject(ElementRef<HTMLElement>).nativeElement;
private count = 0;
constructor() {
afterRender(() => {
this.count++;
this.el.dataset.renders = String(this.count);
});
}
}// zoneless-flag.service.ts — Firebase Remote Config gate
import { Injectable } from '@angular/core';
import { getRemoteConfig, fetchAndActivate, getBoolean } from 'firebase/remote-config';
@Injectable({ providedIn: 'root' })
export class ZonelessFlagService {
private rc = getRemoteConfig();
async enabled(): Promise<boolean> {
await fetchAndActivate(this.rc);
return getBoolean(this.rc, 'signals_zoneless_pilot');
}
}# nx-ci.yaml — guardrails excerpt
name: ci
on: [push, pull_request]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npx nx run web:lint
- run: npx nx run web:test --code-coverage
- run: npx nx run web:e2e --configuration=ci
- run: npx nx run web:devtools-render-check -- --budget=+0 # custom script fails if render counts↑
- run: npx lighthouse http://localhost:4200 --config-path=./lighthouserc.json --budgets-path=./budgets.jsonRxJS → Signals at the edges
Keep your WebSocket/HTTP streams; adapt them at the boundary.
SignalStore skeleton for a critical domain
A minimal slice with computed selectors and typed methods.
Render-count directive for before/after comparisons
Attach to hot components and trend in CI.
Feature-flag the zoneless pilot with Firebase
Flip safely without new builds; keep a server-side override.
Nx CI guardrails
Block merges that increase render counts or fail perf budgets.
Third‑Party Boundaries: PrimeNG and Friends
// primeng-adapter.component.ts
import { Component, input, output, Signal } from '@angular/core';
@Component({
selector: 'app-safe-dropdown',
template: `
<p-dropdown [options]="options()" (onChange)="onChange($event.value)"></p-dropdown>
`,
standalone: true
})
export class SafeDropdownComponent {
options = input.required<any[]>();
changed = output<any>();
onChange(value: any) { this.changed.emit(value); }
}Use SafeDropdownComponent instead of scattering event logic. The store consumes outputs, and templates read only signals. No hidden zone coupling.
Adapter pattern
If a widget mutates DOM without Angular outputs, wrap it so your app updates via signals rather than implicit zone ticks.
Wrap imperative widgets; emit signal updates explicitly.
Keep components OnPush; avoid relying on zones.
Example adapter
This pattern kept a broadcast media network and Charter dashboards stable during rollouts.
Proving Progress with Telemetry
Angular DevTools flame charts plus a simple renderCount directive will tell you if the migration is working. Trend these numbers in CI; block merges that increase renders on critical dashboards.
Metrics to track
On United’s kiosk project we cut unnecessary renders by ~35% before going zoneless—just by moving leaf components to signals and OnPush. Render count deltas are the most convincing executive‑level metric.
Component render counts on hot routes
LCP/INP from Lighthouse and Firebase Performance
Error rate and UI regressions from Cypress visual tests
How an Angular Consultant Approaches Signals Migration
Discovery (days 1–3)
Metric baseline, hot route inventory, 3rd‑party audit
Stabilize (week 1)
OnPush everywhere, leaf Signals, first SignalStore slice
Pilot (week 2)
Boundary adapters, Firebase flag, small zoneless route
Rollout (weeks 3–4)
Typical engagement: 2–4 weeks depending on codebase size and team cadence. If you need to hire an Angular developer with enterprise rescue experience, I can lead the effort or embed with your team.
Widen zoneless coverage, CI budgets, playbook handoff
When to Hire an Angular Developer for Legacy Rescue
See how we can stabilize your Angular codebase and plan a low‑risk migration.
Bring me in if
I’ve modernized payment tracking at a global entertainment company, real‑time ads analytics at a leading telecom provider, and airport kiosks at a major airline with Docker‑based hardware simulation to validate offline‑tolerant flows.
AngularJS/Angular 9–15 app showing performance decay.
High‑stakes SSR or kiosk UX where regressions are unacceptable.
Multi‑tenant dashboards with complex role‑based state.
What you’ll get
If your app is vibe‑coded or AI‑generated, my gitPlumbers system can stabilize it quickly—70% velocity boost and 99.98% uptime across modernizations.
Clear migration plan, guardrails, and measurable outcomes.
SignalStore slices designed for your domain.
A safer path to zoneless change detection with rollback switches.
Concise Takeaways and Next Steps
- Keep zones on while you introduce Signals at the edges and in SignalStore.
- Wrap third‑party boundaries; don’t rely on change detection magic.
- Pilot zoneless behind feature flags; expand only when telemetry is flat.
- Prove progress with render counts, flame charts, and perf budgets in CI.
If you need an Angular expert to design and deliver this migration, let’s review your codebase and roadmap within 48 hours.
Common Questions
How long does the migration take?
Most teams can ship Phase 1–3 in 2–3 weeks. A full zoneless rollout typically follows in the next sprint after a clean pilot. The gating factor is third‑party boundaries and test coverage, not Signals itself.
Do we need to drop NgRx?
No. Keep NgRx for complex effects or event sourcing if it’s working. Use SignalStore for local domain slices and interop via toSignal(). Replace incrementally, not as a big bang.
Will PrimeNG break under zoneless?
Most PrimeNG components are fine. For those relying on implicit zone ticks, wrap them once with a small adapter and emit updates explicitly.
What about SSR?
Signals help determinism. Ensure stable initial values for toSignal() and avoid non‑deterministic subscriptions during render. Use TransferState for HTTP boot data.
Key takeaways
- Keep zone.js on while you introduce Signals at leaf components and stores; turn off zones only after telemetry is flat.
- Use SignalStore for domain state and RxJS→Signal adapters at the edges to avoid rewriting all streams at once.
- Gate the rollout with feature flags (Firebase Remote Config) and CI guardrails (render counts, perf budgets, visual tests).
- Isolate third‑party components behind adapters that manually signal updates instead of relying on zones.
- Prove ROI with Angular DevTools flame charts and render counts before/after each phase.
Implementation checklist
- Baseline UX metrics: render counts, Core Web Vitals, error rate, Lighthouse budgets.
- Turn on OnPush everywhere before Signals; remove accidental two‑way binding where possible.
- Introduce Signals at leaf components; keep services/NgRx selectors stable for now.
- Add SignalStore slices for critical domains; use toSignal() for RxJS interop.
- Wrap third‑party components (PrimeNG/others) with explicit signal updates; avoid zone-dependent hacks.
- Pilot zoneless change detection in a low-traffic route behind a feature flag.
- Rollout zoneless app‑wide after 2+ weeks of flat metrics; keep a fast rollback path.
Questions we hear from teams
- How much does it cost to hire an Angular developer for this migration?
- Typical engagements start with a fixed assessment and a 2–4 week implementation sprint. Pricing depends on team size and risk tolerance. I offer remote Angular consulting with clear milestones and measurable outcomes.
- What does an Angular consultant actually deliver in this migration?
- A phased plan, SignalStore slices, RxJS→Signal adapters, boundary wrappers for third‑party components, CI guardrails, and a zoneless pilot behind feature flags—plus documentation and handoff.
- How long does a zone.js to Signals migration take?
- Plan 2–4 weeks for most enterprise dashboards. Phase 1–3 (Signals + SignalStore) land first; zoneless rolls out after a successful pilot with flat telemetry for 1–2 weeks.
- Will we need to rewrite all our RxJS code?
- No. Keep streams. Use toSignal() at the edges and migrate operators only where they leak into templates. Rewrite only hot paths after you have telemetry.
- What’s involved in a typical Angular engagement with you?
- Discovery within 48 hours, assessment in 5–7 days, first slice shipped in week 1, zoneless pilot in week 2, rollout in week 3–4. Ongoing guidance as needed.
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