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 pragmatic, phase‑by‑phase path to adopt Signals and SignalStore, pilot zoneless change detection, and ship safely with feature flags, tests, and metrics.

Make zoneless boring by the time you flip the flag—Signals and SignalStore should already be driving every meaningful UI update.
Back to all posts

Scene: The Dashboard That Jitters, The Release That Can’t Slip

What I’ve seen in the field

I’m Matthew Charlton. I’ve spent a decade rescuing and upgrading enterprise Angular apps across airlines, telecom, media, insurance, and IoT. The fastest way to blow up a quarter is flipping on zoneless before your state is Signal‑ready. In Angular 20+, Signals and SignalStore let you tighten change detection to the exact data that changed—no halo re-renders, no jitter. The trick is sequencing the migration so UX never regresses. If you’re looking to hire an Angular developer or an Angular consultant to plan this, here’s the battle‑tested approach.

  • Real‑time analytics dashboards (telecom advertising) where filters cause full‑app change detection.

  • Airport kiosk flows where offline retries freeze spinners due to zone.js edge cases.

  • Legacy Angular 8–12 apps where BehaviorSubjects + async pipe hide subtle memory leaks.

Why Plan the Migration in Phases: Angular 20 Signals and Zoneless

What changes when zone.js goes away?

Angular’s Signals give you precise, pull‑based reactivity. SignalStore adds structure for shared state. Zoneless removes zone.js so only signal graph changes trigger renders. Done right, you reduce renders 40–70% and smooth INP outliers. Done wrong, spinners freeze and forms stop updating. Phases prevent surprises.

  • No monkey‑patching of async APIs; Signals drive updates instead of zone microtasks.

  • Events still trigger updates; async code must ultimately write to a signal.

  • Third‑party libs that mutate DOM or state outside Angular won’t auto trigger views.

Guardrails I keep on every upgrade

I wire CI guardrails in Nx so every PR proves we didn’t regress UX. This isn’t theoretical: the same guardrails power AngularUX demos and my live products (gitPlumbers 99.98% uptime; IntegrityLens 12k+ interviews; SageStepper 320 communities).

  • Angular DevTools flame charts to validate render counts.

  • Lighthouse/Core Web Vitals gate in CI (LCP/INP thresholds).

  • Feature flags for risky toggles (zoneless, component pilots).

The Multi‑Phase Plan with Checkpoints and Rollbacks

Example feature flag at bootstrap time:

// main.ts (Angular 20+)
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZoneChangeDetection, provideExperimentalZonelessChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';
import { environment } from './environments/environment';

const zonelessEnabled = environment.zoneless === true;

bootstrapApplication(AppComponent, {
  providers: [
    zonelessEnabled
      ? provideExperimentalZonelessChangeDetection()
      : provideZoneChangeDetection({ eventCoalescing: true, runCoalescing: true })
  ]
});

Phase 0: Baseline and flags

Keep it simple: we don’t need a GA4 deep-dive here—just enough metrics to see render/INP trends. Add an app‑config feature flag so ops can flip zoneless builds during pilots without redeploying.

  • Turn on DevTools profiling, add Lighthouse CI, and expose a runtime flag for zoneless.

Phase 1: Local Component State → Signals

Replacing local streams first removes the biggest source of zone churn: accidental setState‑like cascades.

  • Replace BehaviorSubject with signal.

  • Use input() for inputs, computed() for view derivations.

  • Keep async pipes but prefer signals in templates.

Phase 2: Shared State → SignalStore

SignalStore gives you a predictable place to move updates away from zone timing. Effects stay in RxJS where it shines (backoff, cancellation, multicasting).

  • Create stores per domain (auth, dashboard, filters).

  • Use rxMethod/tapResponse to interop with HTTP/WebSockets.

  • Expose read‑only signals to components.

Phase 3: Change Detection Hygiene

This phase often yields immediate render count drops without touching zone.js. PrimeNG and Angular Material both benefit from proper trackBy and computed selectors.

  • OnPush everywhere, trackBy on repeats, event/run coalescing.

  • Eliminate template pipes that recompute each tick—use computed signals.

Phase 4: Pilot Zoneless Behind a Feature Flag

Pilot the least risky surfaces first—read‑only dashboards, admin reports—before auth flows or payment.

  • Ship a canary route or lazy feature zoneless first.

  • Maintain a fast rollback to zone.js in the same artifact.

Phase 5: Third‑Party and DOM Integration Pass

Airport kiosks and device portals I’ve built often have card readers, printers, scanners. Those callbacks must update signals; then the UI just reacts—no zone magic needed.

  • Wrap DOM listeners with fromEvent and write to signals.

  • Use effects to bridge external callbacks into the reactive graph.

  • Isolate problematic libs behind facades.

Phase 6: Remove zone.js

This is the cleanup step, not the victory lap.

  • Only when pilots are green and telemetry is stable.

  • Drop zone.js, keep the flag for a cycle as a kill‑switch in case of surprises.

Phase 1: Local State to Signals in a Real Component

Before:

@Component({
  selector: 'app-filter-panel',
  template: `
    <p-dropdown [options]="sources$ | async" (onChange)="selectSource($event.value)"></p-dropdown>
    <p-inputText [(ngModel)]="query" (ngModelChange)="onQuery($event)"></p-inputText>
  `
})
export class FilterPanelComponent {
  private destroy$ = new Subject<void>();
  sources$ = this.api.getSources(); // hot observable, zone updates template
  query = '';
  selectedSource$ = new BehaviorSubject<string | null>(null);

  selectSource(id: string) { this.selectedSource$.next(id); }
  onQuery(q: string) { this.query = q; }
}

After:

import { signal, computed, effect, input } from '@angular/core';

@Component({
  selector: 'app-filter-panel',
  template: `
    <p-dropdown [options]="sources()" (onChange)="selectSource($event.value)"></p-dropdown>
    <input pInputText [ngModel]="query()" (ngModelChange)="onQuery($event)" />
  `
})
export class FilterPanelComponent {
  // from API (converted once in a container using toSignal)
  readonly sources = input<any[]>(); // parent provides a signal using input()

  private querySig = signal('');
  private selectedSourceSig = signal<string | null>(null);

  query = this.querySig; // expose read-only handles to template
  selectedSource = computed(() => this.selectedSourceSig());

  selectSource(id: string) { this.selectedSourceSig.set(id); }
  onQuery(q: string) { this.querySig.set(q); }

  // any side-effects that should run on change
  persist = effect(() => {
    // e.g., save to URL/search params or store
    const q = this.query();
    const s = this.selectedSource();
    // side-effect code here
  });
}

Before (zone‑driven)

After (signal‑driven)

Phase 2: Shared State with SignalStore + RxJS Interop

// dashboard.store.ts
import { signalStore, withState, withComputed, withMethods } from '@ngrx/signals';
import { computed, inject } from '@angular/core';
import { rxMethod, tapResponse } from '@ngrx/signals/rxjs-interop';
import { switchMap } from 'rxjs/operators';

interface DashboardState {
  loading: boolean;
  items: ReadonlyArray<Item>;
  filter: string;
}

export const DashboardStore = signalStore(
  { providedIn: 'root' },
  withState<DashboardState>({ loading: false, items: [], filter: '' }),
  withComputed((s) => ({
    filtered: computed(() =>
      s.items().filter(i => !s.filter() || i.name.includes(s.filter()))
    ),
    count: computed(() => s.items().length)
  })),
  withMethods((s) => {
    const api = inject(ApiService);
    const load = rxMethod<void>(switchMap(() => api.getItems()));
    load(
      tapResponse({
        next: (items) => { s.loading.set(false); s.items.set(items); },
        error: (e) => { s.loading.set(false); console.error(e); }
      })
    );

    return {
      setFilter: (q: string) => s.filter.set(q),
      load: () => { s.loading.set(true); load(); }
    };
  })
);
// dashboard.component.ts
@Component({
  selector: 'app-dashboard',
  template: `
    <p-skeleton *ngIf="store.loading()" height="2rem"></p-skeleton>
    <p-inputText [ngModel]="store.filter()" (ngModelChange)="store.setFilter($event)"></p-inputText>
    <p-table [value]="store.filtered()" [rows]="25" [virtualScroll]="true" [trackBy]="trackById"></p-table>
  `
})
export class DashboardComponent {
  constructor(public store: DashboardStore) {}
  trackById = (_: number, item: Item) => item.id;
  ngOnInit() { this.store.load(); }
}

Store setup

  • State first, selectors next, methods last.

  • Expose signal selectors and keep RxJS for I/O.

Component usage

Phase 4: Pilot Zoneless with a Safe Rollback

Nx file replacement example:

// project.json
{
  "targets": {
    "build": {
      "options": { "fileReplacements": [{
        "replace": "src/main.ts",
        "with": "src/main.zoneless.ts"
      }]}
    }
  }
}

Prime checks during pilots:

  • Does the table virtual scroll still repaint correctly when signals change?
  • Do custom form controls (ControlValueAccessor) propagate without zone assistance?
  • Are animations smooth with prefers-reduced-motion respected? If not, move to signal-driven animation state.

Build‑time and runtime flags

  • Two mains: main.zoned.ts and main.zoneless.ts, selected via fileReplace.

  • Runtime switch using environment.zoneless with CI/CD toggles.

What to test first

  • Read‑only routes, dashboards, reports.

  • Form controls with custom value accessors.

  • Third‑party widgets that attach DOM listeners.

Third‑Party Libs, Hardware APIs, and DOM Bridges

@Injectable({ providedIn: 'root' })
export class ScannerFacade {
  private readonly statusSig = signal<'idle'|'scanning'|'error'>('idle');
  status = this.statusSig.asReadonly();

  constructor(private scanner: VendorScanner) {
    fromEvent(this.scanner, 'status')
      .subscribe((s: any) => this.statusSig.set(s));
  }

  start() { this.scanner.start(); this.statusSig.set('scanning'); }
}

Strategy

In airport kiosks, I simulate hardware in Docker and surface events as signals (card inserted, printer ready, network offline). The UI consumes signals; going zoneless then becomes boring—no reliance on global patches.

  • Capture external events with fromEvent or explicit callbacks and write to signals.

  • Use effect() to coordinate write-batching; avoid change detection pokes.

  • Encapsulate vendor quirks behind a facade service.

Example wrapper

When to Hire an Angular Developer for Legacy Rescue

Bring in help if

I’ve migrated employee tracking/payments (global entertainment), airline kiosk software, telecom analytics dashboards, and insurance telematics portals. If you need an Angular expert who has done this before under tight SLAs, let’s talk.

  • Your app mixes AngularJS/Angular 8–12 patterns and production can’t pause.

  • You’re shipping real‑time dashboards, and zone churn is blowing up INP.

  • You depend on heavy PrimeNG tables/forms with custom accessors and validators.

How an Angular Consultant Approaches Signals Migration

My playbook

Zero‑downtime is default. I keep feature flags, blue‑green deploys, and rollback scripts ready. In previous upgrades we cut render counts 50%+ and improved Lighthouse Mobile from the 70s to 90s by pairing Signals with good tokens and trackBy discipline.

  • Discovery (2–3 days): trace hot paths, DevTools renders, dependency map.

  • Stabilize (1–2 weeks): Phase 1–3 changes with PR gates on.

  • Pilot (1–2 weeks): zoneless on canary routes; remediate 3rd‑party issues.

  • Finalize (1 week): remove zone.js, harden tests, document fallbacks.

Code Snippets You’ll Reuse in Every Migration

// container.component.ts
import { toSignal } from '@angular/core/rxjs-interop';

readonly items$ = this.api.items$; // shared observable
readonly items = toSignal(this.items$, { initialValue: [] });
// app.config.ts
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';

export const appConfig: ApplicationConfig = {
  providers: [provideZoneChangeDetection({ eventCoalescing: true, runCoalescing: true })]
};

toSignal for RxJS interop

Coalescing for stability

Takeaways and Next Metrics

What to measure next

End each phase with a measurable win—fewer renders, smoother inputs, no jank. That’s how you prove progress to stakeholders and why teams bring in a senior Angular engineer to lead this work.

  • Render counts per interaction (Angular DevTools).

  • INP p95 on key flows before/after zoneless pilots.

  • User‑perceived latency with skeletons vs spinners.

Questions

Related Resources

Key takeaways

  • Treat zoneless as the final step, not the first—migrate state to Signals/SignalStore and pay down OnPush debt first.
  • Use feature flags to pilot zoneless on low-risk routes; keep a fast rollback to zone.js during the transition.
  • Translate hot RxJS streams to Signals with toSignal and keep WebSocket/telemetry in RxJS.
  • Protect UX with Angular DevTools, Lighthouse CI gates, and user-visible loading/skeleton states during each phase.
  • Wrap third‑party libs and DOM listeners so state updates are signal-driven rather than zone-driven.

Implementation checklist

  • Baseline: turn on Angular DevTools change detection profiling and add Lighthouse CI gate in CI.
  • Phase 1: convert local component state (BehaviorSubject, @Input) to signal(), input().
  • Phase 2: introduce SignalStore for shared state; keep effects in RxJS where streams are hot.
  • Phase 3: upgrade change detection hygiene (OnPush everywhere, trackBy, coalescing).
  • Phase 4: feature-flag a zoneless pilot build and toggle per-environment/route.
  • Phase 5: remediate third‑party libs using effects, fromEvent, and manual scheduling.
  • Phase 6: remove zone.js only when all critical paths are signal-driven and green in tests.

Questions we hear from teams

How long does a zone.js → Signals/SignalStore migration take?
For a typical enterprise dashboard, plan 2–4 weeks for Phase 1–3 (Signals + SignalStore + hygiene), 1–2 weeks for a zoneless pilot, and 1 week to finalize. Larger monoliths can parallelize by domain to stay within a quarter.
Will we need to rewrite NgRx effects or WebSockets?
No. Keep hot streams (HTTP, WebSockets) in RxJS. Convert consumption to Signals via toSignal or a SignalStore. You gain fine-grained updates without losing cancellation, backoff, or typed event streams.
What breaks when removing zone.js?
Async code that doesn’t ultimately set a signal won’t update the view. Third‑party libs that mutate the DOM without emitting data into Angular need wrappers. The plan above addresses both with effects and facades.
What does a typical Angular engagement with you look like?
Discovery and assessment within 48 hours, a written migration plan in 1 week, and phased delivery with CI guardrails. I’m a remote Angular consultant available for 1–2 projects per quarter, focused on upgrades and rescue work.
How much does it cost to hire an Angular developer for this migration?
Fixed‑scope pilots start affordably; full migrations are usually milestone‑based. After a brief code review, I’ll propose a plan with timelines and risk‑based pricing so you can compare against internal effort and opportunity cost.

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 how I rescue chaotic code with gitPlumbers

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