
Plan a Multi‑Phase Migration from zone.js to Signals + SignalStore (Angular 20+)—Without Breaking UX
A practical, phased roadmap to adopt Signals and SignalStore, de‑risk zoneless change detection, and keep production stable while you ship.
Flip zoneless per route behind a flag, not across the entire app. Migrate leaves → stores → routes. Prove each step with metrics, then delete zone.js.Back to all posts
I’ve led zone.js→Signals migrations in apps that couldn’t pause delivery—employee tracking for a global entertainment company, an airport kiosk platform, and a telecom analytics dashboard. The playbook below is the one I use: phase the change, instrument everything, and flip zoneless per‑feature behind flags so UX never wobbles.
The scene: a dash that jitters and a CFO watching Lighthouse
Been there
Your Angular 15–18 dashboard jitters under WebSocket traffic. Devs sprinkle ChangeDetectorRef.detectChanges() like confetti, and INP spikes during peak usage. Leadership asks for measurable performance wins—without a feature freeze. As we plan 2025 roadmaps, this is where Signals + SignalStore shine—if you migrate safely.
Why me
I’m Matthew Charlton, AngularUX. I specialize in enterprise dashboards, real‑time telemetry, role‑based multi‑tenant apps, and stabilizing chaotic codebases. If you need to hire an Angular developer or bring in an Angular consultant to de‑risk Signals, this roadmap is built from production wins.
Fortune 100 Angular upgrades under active development
Kiosk software with offline‑tolerant flows and hardware simulation
Real‑time analytics dashboards at telecom scale
Why Angular 20+ teams should move beyond zone.js
Signals cut renders and cognitive load
Signals replace heuristic change detection with explicit dependencies. In one refresh, moving to Signals + tokens cut render counts 71% and lifted Lighthouse Mobile 72→94. You get determinism, simpler tests, and predictable hydration.
Zoneless reduces hidden work
Zone.js can wake components up for irrelevant events. Zoneless makes change detection opt‑in—via signals, input()s, and explicit triggers. You’ll need to account for third‑party libs and async edges, which is why we phase it.
SignalStore aligns state with UI reactivity
NgRx SignalStore provides ergonomic, framework‑native stores: withState, withComputed, withMethods, no reducers/effects boilerplate. It’s ideal for feature domains feeding dashboards, and it composes cleanly with RxJS streams when needed.
The multi‑phase roadmap
Here’s the minimal scaffolding I use to wire flags and zoneless in Angular 20+. Toggle at bootstrap, but read the flag dynamically so you can run both modes in CI and dark‑launch in prod.
Phase 0 — Instrument and baseline
Start by measuring. Tag top 5 user flows and 3 busiest routes. Capture render counts per route with Angular DevTools, and log slow interactions to GA4/BigQuery. Without baseline metrics, you can’t prove the migration paid off.
Angular DevTools flame charts
Core Web Vitals (INP/LCP/CLS)
Error rates and hydration warnings
Route paint times and render counts
Phase 1 — Audit change detection boundaries
Make a list of any place zone.js masks work: timers, direct DOM events, and libraries that mutate outside Angular. These become your explicit trigger points later—either via signals exposure or a tiny bridge service.
setTimeout/interval, event listeners, WebSockets
Third‑party widgets (charts, maps)
PrimeNG/Material dynamic overlays and portals
Phase 2 — Convert leaf components to Signals
Pick a leaf route. Replace @Input() with inputs(), derive view models with computed(), and remove superfluous async pipes. This immediately reduces renders before zoneless is even enabled.
inputs() or model() for inputs
computed() for derived state
effect() for side‑effects
Phase 3 — Bridge RxJS to Signals
Expose WebSocket/HTTP via typed adapters. Keep SSR deterministic by avoiding implicit subscriptions in constructors—prefer providers and effect()s. This phase stabilizes realtime dashboards without jank.
toSignal for cold/finite streams
toObservable for signal → effects
Retry/backoff and typed schemas for realtime
Phase 4 — Lift domain state into SignalStore
Move feature state into SignalStore for each domain (auth, preferences, devices). Components become thin consumers of explicit signals, unlocking zoneless per‑route.
Encapsulate read/write in store methods
computed() selectors derived from state
Test stores in isolation
Phase 5 — Gate zoneless with flags
Flip zoneless in one route at a time behind a flag. Watch INP/LCP, overlay behaviors (menus, dialogs), and 3rd‑party charts. Rollback is a flag change, not a redeploy.
Firebase Remote Config flag per route/feature
Dual CI jobs: zoned + zoneless
Hydration and overlay smoke tests
Phase 6 — Remove zone.js
After stable production runs in zoneless mode, remove zone.js and dead code. Keep your explicit integrations (charts/maps) as they document intent and prevent regressions.
Keep explicit triggers for 3rd‑party libs
Delete detectChanges() workarounds
Monitor for 2–4 weeks before pruning
Feature flags, zoneless bootstrap, and SignalStore examples
// app.config.ts
import { ApplicationConfig, isDevMode } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideExperimentalZonelessChangeDetection, provideZoneChangeDetection } from '@angular/core';
import { routes } from './app.routes';
export function provideZoneless(enabled: boolean) {
return enabled
? provideExperimentalZonelessChangeDetection()
: provideZoneChangeDetection({ eventCoalescing: true, runCoalescing: true });
}
export const appConfig = (zonelessEnabled: boolean): ApplicationConfig => ({
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([])),
provideAnimations(),
provideZoneless(zonelessEnabled),
],
});// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
import { initializeApp } from 'firebase/app';
import { getRemoteConfig, fetchAndActivate, getValue } from 'firebase/remote-config';
async function getZonelessFlag(): Promise<boolean> {
try {
const app = initializeApp({ /* your Firebase config */ });
const rc = getRemoteConfig(app);
rc.settings.minimumFetchIntervalMillis = 60_000;
await fetchAndActivate(rc);
return getValue(rc, 'zoneless_enabled').asBoolean();
} catch {
return false;
}
}
getZonelessFlag().then(flag => bootstrapApplication(AppComponent, appConfig(flag)));// leaf.component.ts – migrate to signals
import { Component, computed, effect, input, signal } from '@angular/core';
@Component({
selector: 'app-leaf',
template: `
<p>Hi {{ name() }}!</p>
<p *ngIf="greeting() as g">{{ g }}</p>
`,
standalone: true,
})
export class LeafComponent {
name = input.required<string>();
clicks = signal(0);
greeting = computed(() => `Welcome, ${this.name()} (${this.clicks()} clicks)`);
constructor() {
effect(() => {
// analytics side effect example
console.debug('greeting changed', this.greeting());
});
}
}// rx-to-signals.ts – typed RxJS → Signals adapter
import { toSignal } from '@angular/core/rxjs-interop';
import { catchError, map, retryBackoff } from 'rxjs/operators';
import { Observable, of } from 'rxjs';
declare function retryBackoff(options: { initialInterval: number; maxInterval: number; }): any;
export function toTypedSignal<T>(source$: Observable<T>, initial: T) {
return toSignal(
source$.pipe(
retryBackoff({ initialInterval: 500, maxInterval: 10_000 }),
catchError(err => of(initial))
),
{ initialValue: initial }
);
}// preferences.store.ts – SignalStore example
import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
interface PrefsState {
theme: 'light' | 'dark' | 'system';
density: 'comfortable' | 'compact';
}
const initialState: PrefsState = { theme: 'system', density: 'comfortable' };
export const PreferencesStore = signalStore(
withState(initialState),
withComputed(({ theme, density }) => ({
isDark: () => theme() === 'dark',
classList: () => `theme-${theme()} density-${density()}`,
})),
withMethods((store) => ({
setTheme(theme: PrefsState['theme']) { patchState(store, { theme }); },
setDensity(density: PrefsState['density']) { patchState(store, { density }); },
}))
);<!-- app.component.html – using PrimeNG + store -->
<p-toolbar [styleClass]="prefs.classList()">
<button pButton label="Dark" (click)="prefs.setTheme('dark')"></button>
<button pButton label="Light" (click)="prefs.setTheme('light')"></button>
</p-toolbar>
<router-outlet></router-outlet># ci.yml – dual-mode e2e to protect UX
name: e2e
on: [push]
jobs:
e2e-zoned:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm run test:e2e -- --env ZONELESS=0
e2e-zoneless:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm run test:e2e -- --env ZONELESS=1Bootstrap with zoneless behind a flag
Use Firebase Remote Config (or env) to gate zoneless. During migration, keep both paths available.
Leaf component migrating to signals
Inputs become inputs(), derived state moves to computed(), and side‑effects sit in effect(). No more accidental re-renders.
SignalStore for a preferences domain
Stores encapsulate reads/writes and surface computed selectors. Components stay declarative and framework‑native.
CI guardrails
Run both zoned and zoneless e2e. Fail fast if hydration or overlay regressions appear.
Gotchas and bridges when you go zoneless
Third‑party libraries (charts, maps)
Libraries that mutate outside Angular won’t trigger renders in zoneless mode. Wrap them, promote state to signals, and call set() on event callbacks. Keep the wrapper even after migration—it documents the contract.
Wrap in a directive and expose an update() signal.
Re-emit async events into Angular via a signal/set().
Overlays and portals (PrimeNG/Material)
Overlay render timing can expose hidden zone dependencies. Exercise keyboard traps, focus management, and animations. PrimeNG behaves well, but custom directives may need explicit signals to reflect anchor changes.
Test dialogs, menus, tooltips under zoneless.
Prefer inputs() + signal‑backed templates.
SSR hydration
Zoneless + SSR is deterministic, but don’t run network calls in constructors. Hydration warnings are often a signal that a side‑effect mutated state pre‑render.
Prefer pure computed(), avoid side‑effects in constructors.
Use effect() lazily after view init when needed.
When to Hire an Angular Developer for Legacy Rescue
Bring help if you see these signals
If this list feels familiar, a short engagement can set patterns, wire flags, and convert your first domain store. I’ve done this on employee tracking, ads analytics, VPS schedulers, and telematics dashboards.
detectChanges() scattered in components
WebSockets cause jank and missed updates
AngularJS/older Angular mixed with new code
CI lacks e2e coverage for dialogs/overlays
Engagement shape
As a remote Angular consultant, I align with your PM’s roadmap and instrument real metrics so leadership sees progress. If you need an Angular expert for hire, I can join quickly and leave you with durable patterns.
Discovery/assessment in 1 week
First domain migrated in 2–4 weeks
Parallel delivery—no feature freeze
What to measure and how to prove the win
Before/after metrics that matter
Target a 30–50% render reduction on busy routes and a measurable INP improvement. Use flame charts to validate that computed() and SignalStore selectors flatten work during interactions.
Render counts per route (Angular DevTools)
INP/LCP (Lighthouse CI + GA4 RUM)
Hydration warnings and error rates
CPU time under WebSocket burst
Telemetry pipeline
Use a typed event schema so your dashboards show whether the user was in zoned or zoneless mode. This makes regression analysis straightforward during rollout.
Typed event schemas
Feature flag dimension (zoned/zoneless)
Exponential retry for event delivery
Concise takeaways and next steps
- Treat zoneless as a capability you can turn on by route, not a one‑time switch.
- Convert leaf components first, then adopt SignalStore per domain.
- Bridge RxJS with typed adapters and keep third‑party wrappers explicit.
- Gate with Remote Config, run dual e2e in CI, and measure everything.
If you’re planning your 2025 Angular roadmap and want a safe migration path, I’m available for hire. Let’s review your codebase, plan the phases, and start shipping wins without a freeze.
FAQ: Plan your migration
Key takeaways
- Treat zoneless as a feature, not a flag flip—migrate domains in phases behind feature flags.
- Instrument first: baseline render counts, INP/LCP, error rates, and user paths so you can prove wins.
- Start at the edges: convert leaf components to Signals, then lift domain state into SignalStore.
- Bridge RxJS with typed adapters and isolate legacy OnPush code using explicit triggers before going zoneless.
- Gate zoneless with Remote Config, run dual CI jobs (zoned/zoneless), and cut over by route or feature.
- Remove zone.js only after third‑party libs and hydration pass in production under flags.
Implementation checklist
- Baseline metrics: Angular DevTools flame charts, Core Web Vitals, error rates, route paints.
- Audit change detection boundaries: where setTimeout/DOM events/3rd‑party libs poke change detection.
- Introduce feature flags (Firebase Remote Config) for per‑route zoneless toggles.
- Migrate leaf components to signals: inputs(), model(), computed(), effect().
- Adopt SignalStore per domain with withState/withComputed/withMethods.
- Create typed RxJS→Signals adapters (toSignal, toObservable) with retry/backoff.
- Stage zoneless by feature/route; run e2e in both modes in CI.
- Verify SSR hydration, PrimeNG/Material behaviors, and accessibility states.
- Cut zone.js and remove workarounds once production is clean for 2–4 weeks.
Questions we hear from teams
- How long does a zone.js → Signals + SignalStore migration take?
- For a typical enterprise module, expect 2–4 weeks per feature domain: one week to instrument and convert leaves, one to wire SignalStore, and one for zoneless rollout and fixes. Large platforms migrate per route over a quarter with no feature freeze.
- Will PrimeNG and Angular Material work without zone.js?
- Yes, with care. Most components bind to inputs/signals and work fine. Exercise overlays, menus, and tooltips in e2e. Where libraries mutate outside Angular, wrap in a directive and push updates via signals.
- Do we need NgRx if we adopt SignalStore?
- SignalStore is part of NgRx’s Signals package. It covers most feature state. Keep RxJS for streams, WebSockets, or effects-like workflows. Use toSignal and toObservable to bridge deterministically.
- What does an Angular consultant actually deliver here?
- A phased plan, feature flags, initial SignalStore domains, typed RxJS→Signals adapters, CI jobs for zoned/zoneless, and guardrail tests for overlays/hydration. I also train your team so the pattern scales across routes.
- What’s a typical engagement and cost?
- Discovery and assessment in 1 week, then 2–4 weeks to deliver the first migrated domain with CI guardrails. Pricing depends on scope. I’m a remote Angular contractor and can start within 1–2 weeks for qualified teams.
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