Plan a Multi‑Phase Migration from zone.js to Signals + SignalStore (Angular 20+)—Without Breaking UX

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=1

Bootstrap 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

Related Resources

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.

Hire Matthew – Remote Angular Expert (Signals + Zoneless) See NG Wave – 110+ Angular Signals Components

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