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 throughservice.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
asyncpipe 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
StoreandSignalservices.
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 signalsfor any data that is shared across lazy-loaded boundaries. - Use Signals for UI State Only: Allow
writable signalsstrictly 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
Signalsto 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 tostore.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).