From zone.js to Signals + SignalStore in Angular 20: A Multi‑Phase Migration That Won’t Break UX

From zone.js to Signals + SignalStore in Angular 20: A Multi‑Phase Migration That Won’t Break UX

A practical, CI‑guarded plan to move from zone.js change detection to Signals + SignalStore in Angular 20—measured, reversible, and safe for production UX.

“Make zoneless the last switch. First make your edges and state Signals‑aware, instrument everything, and keep a rollback lever within reach.”
Back to all posts

I’ve migrated jittery real‑time dashboards, kiosk apps, and multi‑tenant portals to Angular 20+ Signals without torching UX. The pattern that works is boring on purpose: measure first, flip small switches, and keep a rollback lever. This guide is the playbook I’ve used at a telecom (ads analytics), a major airline (airport kiosk), and across my own products at AngularUX.

If you’re planning Q1 migrations or want an Angular expert to steady a legacy codebase, this shows exactly how I phase the move from zone.js to Signals + SignalStore—using Nx, Firebase, PrimeNG, Angular DevTools, and CI guardrails.

The Dashboard That Jittered—and the Day We Stopped Chasing Ticks

A real scene from production

At a telecom, our ad‑ops dashboard spiked to 5k events/minute. Zone.js thrashed: charts re‑rendered mid‑pan, PrimeNG tables lost scroll position, and support tickets lit up. We phased in Signals + SignalStore, added typed event schemas, and cut unnecessary change detection by 60% while improving p95 render by 27%—with zero user‑visible downtime.

Why a phased plan wins

Signals are fast, but migrations fail when teams leap straight to zoneless. Instead, make zoneless the last switch after edges and state are signal‑aware and observable.

  • Reversible at every step

  • No “big bang” outage windows

  • Telemetry‑driven trust with stakeholders

Why Angular 20 Teams Should Ditch zone.js Gradually

The technical rationale

Zone.js hid a lot of antipatterns that crept into codebases over the years. Signals make reactivity explicit and predictable. In Angular 20+, you get input signals, toSignal bridges for RxJS, and SignalStore for ergonomic domain state.

  • Signals eliminate excess dirty‑checking

  • Explicit effects beat implicit zone churn

  • Fine‑grained updates scale better for virtualized UIs

The delivery rationale

Treat this as a product change with feature flags, telemetry, and rollbacks. It keeps leadership calm and your roadmap intact.

  • Stakeholder‑friendly metrics (LCP, TTI, error rate)

  • Rollout by cohort (canary ➜ internal ➜ 5% ➜ 25% ➜ 100%)

  • No late‑night fire drills

Multi‑Phase Migration Plan: Signals, SignalStore, Zoneless

Phase 0 — Baseline and Risk Map

Before touching code, measure. In CI, add Lighthouse budgets and a threshold for error rates. In prod, log NgZone stability and route‑level timings to Firebase Performance/GA4.

  • Capture Core Web Vitals (LCP, INP), error rate, and render timings

  • Record change detection ticks on key routes with Angular DevTools

  • Tag high‑risk surfaces: PrimeNG tables, forms, WebSocket dashboards

Phase 1 — Introduce Signals at the Edges

Start where data enters the UI. Convert key RxJS streams to signals with toSignal, and introduce computed selectors near components. No user‑visible change yet, but ticks will drop.

  • Wrap network and stream sources with toSignal

  • Replace template async pipes gradually with signal reads

  • Keep OnPush everywhere; avoid creating new zone dependencies

Phase 2 — Adopt SignalStore for Domain State

Don’t rip out NgRx. Coexist. Use selectSignal to expose existing selectors to signal‑based components, and gradually move slices to SignalStore with typed events.

  • Create feature stores with explicit mutators and derived selectors

  • Bridge NgRx where needed via selectSignal

  • Document selectors/mutators for reviewers

Phase 3 — Gate Zoneless Behind a Feature Flag

Only when edges and stores are signals‑aware do we flip zoneless for canary users. Watch p95 hydration and INP. If anything regresses, turn the flag off—no redeploy needed.

  • Bootstrap with a runtime flag (Remote Config)

  • Rollout by audience cohort; watch hydration and error metrics

  • Keep instant rollback to zone.js

Phase 4 — SSR/Hydration, Accessibility, and UX Polish

Signals help you make accessibility updates deterministic. Ensure SR announcements and focus change via effects rather than zone‑triggered timing hacks.

  • Verify hydration timings and missing bindings

  • Focus/scroll management without zone side‑effects

  • ARIA/live‑region updates powered by effects

Phase 5 — Cleanup and Deprecations

Now make it boring: clean, measure, and move on. Document the new signal‑first patterns for new hires.

  • Remove zone.run wrappers and accidental global listeners

  • Delete brittle tests that relied on zone microtask timing

  • Lock in budgets and dashboards for ongoing monitoring

Code Walkthrough: SignalStore Bridges and Flagged Zoneless

Edge conversion: RxJS ➜ Signal

// user.data.service.ts
@Injectable({ providedIn: 'root' })
export class UserDataService {
  private users$ = this.http.get<User[]>('/api/users').pipe(shareReplay(1));
  users = toSignal(this.users$, { initialValue: [] });
  // Derived signal keeps templates simple
  activeCount = computed(() => this.users().filter(u => u.active).length);
  constructor(private http: HttpClient) {}
}

Coexist with NgRx using selectSignal

// adapters/ngrx-to-signals.ts
@Injectable({ providedIn: 'root' })
export class AccountsAdapter {
  total = selectSignal(this.store, selectTotalAccounts);
  selected = selectSignal(this.store, selectActiveAccount);
  constructor(private store: Store) {}
}

SignalStore slice with explicit mutators

// stores/devices.store.ts (@ngrx/signals)
import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';

export type Device = { id: string; status: 'online'|'offline'; lastSeen: number };
export interface DevicesState { list: Device[]; filter: 'all'|'online'|'offline'; }

const initialState: DevicesState = { list: [], filter: 'all' };

export const DevicesStore = signalStore(
  withState(initialState),
  withMethods((store) => ({
    setDevices(list: Device[]) { patchState(store, { list }); },
    setFilter(filter: DevicesState['filter']) { patchState(store, { filter }); },
    markOnline(id: string) {
      patchState(store, ({ list }) => ({
        list: list.map(d => d.id === id ? { ...d, status: 'online', lastSeen: Date.now() } : d)
      }));
    }
  }))
);

@Injectable({ providedIn: 'root' })
export class DevicesFacade extends DevicesStore {
  filtered = computed(() => {
    const f = this.filter();
    return f === 'all' ? this.list() : this.list().filter(d => d.status === f);
  });
}

Feature‑flagged zoneless bootstrap (Firebase Remote Config)

// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideHttpClient } from '@angular/common/http';
import { AppComponent } from './app/app.component';
import { getRemoteFlag } from './app/flags';
// Angular 20+: provider name may vary; keep it behind a flag either way
import { provideExperimentalZonelessChangeDetection } from '@angular/core';

(async () => {
  const zoneless = await getRemoteFlag('zoneless_enabled');
  const providers = [provideHttpClient()];
  if (zoneless) providers.push(provideExperimentalZonelessChangeDetection());

  await bootstrapApplication(AppComponent, { providers });
})();

CI guardrails with Lighthouse and budgets

# .github/workflows/ci.yml (Nx + Lighthouse + budgets)
name: ci
on: [push, 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 run-many -t lint,test,build --parallel
      - name: Lighthouse CI
        run: |
          npx http-server dist/apps/web -p 4200 &
          npx lhci autorun --upload.target=temporary-public-storage
      - name: Budget check
        run: npx bundlesize

When to Hire an Angular Developer for Legacy Rescue

Red flags I see before migrations stall

If your app looks like this, bring in an Angular consultant early. You’ll ship faster and avoid death‑by‑refactor. I stabilize codebases via gitPlumbers (99.98% uptime on modernization work) and have done AngularJS ➜ Angular and JSP ➜ Angular lifts that stuck.

  • Unowned RxJS streams, memory leaks, zombie subscriptions

  • PrimeNG tables stutter with virtualization on

  • Forms rely on zone.run timing hacks

Outcomes you can expect

I’ve delivered this approach on a major airline kiosk (Docker hardware simulation, offline‑tolerant flows) and a telecom dashboard (typed telemetry, exponential retry).

  • Signal‑first state with measurable perf gains

  • A canary+flag rollout plan and a rollback switch

  • Documentation that lets new hires audit state paths

How an Angular Consultant Approaches Signals Migration

Engagement rhythm (typical)

Complex estates vary, but 4‑8 weeks handles most upgrades. For hotfix rescues, 2‑4 weeks. Discovery call within 48 hours; initial assessment in 5 business days.

  • Week 1: audit + metrics + risk map

  • Weeks 2‑3: Phase 1/2 implementation + CI guardrails

  • Week 4: canary zoneless + support + handoff

What I bring

Past work: employee tracking/payments at a global entertainment company, VPS schedulers for a broadcast network, insurance telematics dashboards, device portals for IoT fleets.

  • Angular 20, Signals/SignalStore, Nx, PrimeNG, Firebase

  • Real‑time dashboards, device integration, SSR, accessibility AA

  • Telemetries: Angular DevTools, GA4, Firebase Performance

Practical Takeaways and Next Steps

What to instrument next

Tie telemetry to releases so you can prove the win: Signals reduce ticks and stabilize UI under load. Keep the budgets in CI so regressions never merge.

  • Change detection ticks per route after each phase

  • Hydration p95 and INP deltas in CI and prod

  • Error taxonomy tied to state mutators/effects

Ready to move?

If you need an Angular expert to guide the rollout, I’m available as a remote Angular contractor. Let’s review your repo, agree on metrics, and ship the migration without risking UX.

  • Start with Phase 0 this week—no code changes required

  • Pilot Phase 1 on a single dashboard route

  • Book a code review to validate your plan

Questions to Ask Before Flipping Zoneless

Pre‑flight checklist

If any answer is “no,” finish that item before canary. The extra day now saves a week later.

  • Do we have a killswitch and a rollback to zone.js?

  • Which routes show the biggest tick reductions under Signals?

  • Are forms, virtual scroll, and WebSockets covered by tests?

  • Do we have p95 hydration/INP alerts and SLOs in place?

  • Are selectors/mutators documented so reviewers can reason about state?

Related Resources

Key takeaways

  • Treat zoneless as a product change, not a refactor—baseline UX and error rates first.
  • Introduce Signals at the edges, then move into domain state with SignalStore.
  • Bridge RxJS/NgRx to Signals using toSignal/selectSignal and typed event schemas.
  • Flip zoneless behind a remote feature flag; canary, observe, roll back instantly.
  • Instrument hydration, change detection ticks, and render timings in CI and prod.
  • Clear out zone‑dependent utilities only after zoneless is stable under real traffic.

Implementation checklist

  • Baseline metrics: Core Web Vitals, Angular DevTools change detection ticks, error rates.
  • Map risk: heavy PrimeNG tables, forms, and WebSocket dashboards first.
  • Introduce toSignal at API edges; wrap UI‑critical selectors as computed.
  • Create SignalStore slices with explicit mutators; keep NgRx coexisting via adapters.
  • Gate zoneless bootstrap with a remote flag; test on internal canary first.
  • Add Lighthouse/LCP budgets and Angular DevTools screenshot traces in CI.
  • Ship a rollback plan: environment fallback to zone.js and feature flag killswitch.
  • Audit and remove zone.run wrappers, async pipes depending on Zone, and flaky tests.
  • Document selectors, mutators, and telemetry hooks for reviewers.
  • Schedule incremental rollouts and office‑hours to support downstream teams.

Questions we hear from teams

How long does a zone.js ➜ Signals + SignalStore migration take?
Most teams ship in 4–8 weeks. I run Week 1 for audit/metrics, Weeks 2–3 for Signals at edges and SignalStore slices, and Week 4 for canary zoneless. Larger estates or heavy SSR may add 2–4 weeks.
Do we need to replace NgRx to use SignalStore?
No. Bridge NgRx with selectSignal and move slices over gradually. Many enterprise apps run NgRx for legacy modules and SignalStore for new features without issues.
Will zoneless break third‑party libraries like PrimeNG?
PrimeNG works well with OnPush and Signals. Test virtualization, overlays, and forms in canary. Keep a feature flag to fall back to zone.js instantly if a regression appears.
What does an Angular consultant actually deliver here?
A phased plan with metrics, code adapters, CI guardrails, and a flagged zoneless rollout. I pair with your team, refactor critical routes, and leave documentation and dashboards so the approach sticks.
How much does it cost to hire an Angular developer for this migration?
It depends on scope and team size. Typical engagements range 4–8 weeks. I offer a fixed‑fee assessment and milestone‑based delivery. Discovery call within 48 hours; assessment in 5 business days.

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 (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