Angular Signals Migration with NgRx State Synchronization

Summary

The engineering team attempted to migrate a massive enterprise Angular application (70+ modules) to a signals-first architecture while maintaining an existing NgRx (Store + Effects) backbone. The core tension lies in deciding where the Source of Truth resides: should state be duplicated into writable signals within services, or should signals serve strictly as a reactive projection of the NgRx Store? Failure to define this boundary leads to state synchronization drift, where the local signal state and the global store state eventually diverge, causing non-deterministic UI bugs.

Root Cause

The architectural friction stems from a misunderstanding of the State Ownership Model. When developers introduce writable signals inside services to manage data that already exists in NgRx, they create two independent mutation paths:

  • Dual Mutation Paths: One path through store.dispatch(action) and another through service.setUsers(data).
  • Synchronization Lag: Even if a mechanism is built to sync the two, there is an inherent temporal gap between the Store update and the Signal update.
  • Loss of Traceability: NgRx provides a centralized Redux DevTools timeline. Writable signals in services bypass the Action/Reducer lifecycle, making it impossible to debug why a state change occurred.

Why This Happens in Real Systems

In large-scale enterprise environments, this “split-brain” architecture occurs due to several systemic pressures:

  • Incremental Migration: Teams cannot rewrite 70+ modules overnight. They attempt to “sprinkle” signals into new features, creating a hybrid state model.
  • Developer Ergonomics: Signals are significantly easier to use in templates than RxJS Observables (no async pipe required), tempting developers to bypass the formal Store pattern for “simplicity.”
  • Lazy Loading Complexity: Feature modules often want to encapsulate their own state. Developers mistakenly believe that moving state from a global NgRx Store to a local providedIn: 'root' signal service is a form of encapsulation, when it is actually fragmentation.

Real-World Impact

  • Heisenbugs: A user updates their profile in a feature module (updating a local signal), but navigates to a different module that relies on the NgRx Store, seeing old, stale data.
  • Increased Memory Footprint: Maintaining two versions of the same large data arrays (one in the Store, one in a Signal service) increases heap usage.
  • Testing Complexity: Unit tests must now account for two different state management paradigms, requiring different mocking strategies for both Store and Signal services.

Example or Code

import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { selectUsers } from './user.selectors';
import { toSignal } from '@angular/core/rxjs-interop';

@Injectable({ providedIn: 'root' })
export class UserFacade {
  private readonly store = inject(Store);

  // RECOMMENDED: Signal as a projection of the Store
  // This maintains a SINGLE source of truth (NgRx)
  readonly users = toSignal(this.store.select(selectUsers), { initialValue: [] });

  // DISCOURAGED: Creating a second, writable source of truth
  // private _localUsers = signal([]); 

  updateUser(user: User) {
    // Always route mutations through the formal dispatch mechanism
    this.store.dispatch(UserActions.updateUser({ user }));
  }
}

How Senior Engineers Fix It

A senior engineer enforces a unidirectional data flow where Signals act as the View Layer projection, not the Data Layer storage.

  • Define Signal Roles: Establish a rule that Signals are Read-Only Projections of the Store. Use toSignal() to transform NgRx selectors into signals for component consumption.
  • Preserve the Reducer Lifecycle: Prohibit the use of writable signals for any data that is shared across lazy-loaded boundaries.
  • Use Signals for UI State Only: Allow writable signals strictly for ephemeral, non-persistent UI state (e.g., isExpanded, activeTabId, searchQueryFilter) that does not need to be part of the global application state.
  • Standardize the Facade Pattern: Use an Injectable Facade to wrap the NgRx Store. The Facade exposes Signals to the components, hiding the complexity of RxJS/NgRx while maintaining the integrity of the Store.

Why Juniors Miss It

  • Focus on Syntax over Architecture: Juniors often focus on how “clean” the component code looks with signals and ignore the underlying data integrity implications.
  • The “Simplicity Trap”: They see signal.set() as a simpler alternative to store.dispatch(), failing to realize that simplicity in writing code often leads to complexity in debugging.
  • Lack of Distributed Systems Thinking: They treat the application as a single local environment rather than a complex system where different modules (lazy-loaded chunks) must synchronize via a reliable, audited medium (the Store).

Leave a Comment