Architecting Fine‑Grained State with Angular Signals
Learn how to replace Zone.js‑based change detection with Angular Signals to eliminate unnecessary component re‑renders and implement a strict, read‑only state management pattern.
24 Mar 2026, 00:44 UTC

The Problem: Component Tree Over‑Rendering
In traditional Angular applications, Zone.js monitors all asynchronous events (clicks, timers, HTTP responses) and triggers a top‑down change detection cycle. As applications grow, this "dirty checking" of the entire component tree leads to performance degradation, even when only a single piece of data in a deeply nested component has changed.
The takeaway: By migrating shared state from RxJS BehaviorSubjects to Signals, you shift from a global "check everything" model to a fine‑grained model where Angular knows exactly which DOM nodes depend on specific state changes, reducing CPU overhead and eliminating unnecessary component re‑renders.
The Smallest Suitable Design
The most efficient implementation of signal‑based state is a Service‑Store pattern. Instead of exposing streams that components must subscribe to (and manually unsubscribe from), the service manages the state as a signal and exposes it as a read‑only view.
Core Implementation Example
This pattern assumes Angular v17+ where Signals are stable. The goal is to encapsulate the writable state and derive dependent values using computed() to avoid redundant calculations.
// state.service.ts
import { Injectable, signal, computed } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class UserPreferencesService {
// Private writable signal: The single source of truth
private _theme = signal<'light' | 'dark'>();
// Public read‑only signal: Components cannot mutate this directly
readonly theme = this._theme.asReadonly();
// Derived state: Only re‑calculates when _theme changes
readonly themeClass = computed(() =>
this._theme() === 'light' ? 'bg-white text-black' : 'bg-gray-900 text-white'
);
updateTheme(newTheme: 'light' | 'dark') {
this._theme.set(newTheme);
}
}
Trust and Data Boundaries
A common failure in state management is "leakage," where components mutate state directly, making debugging impossible. To establish a strict data boundary:
- Private Writable Signals: Always declare the primary state as
private _state = signal(...). - Read‑Only Exposure: Use the
.asReadonly()method. This ensures that components can only read the value or trigger a change via a defined method in the service. - Computed Boundaries: Use
computed()for any logic that transforms state for the UI. This prevents the component from having to perform logic inside the template orngOnChanges.
Operational Checks and Verification
Because Signals track their own dependencies, you no longer need to manually trigger change detection or use the async pipe for simple state. To verify the efficiency of this design:
1. DOM Update Isolation
Create a parent component with two children. One child should bind to the signal; the other should be a static component. Update the signal and observe the browser's Angular DevTools. Only the component bound to the signal should be marked for check.
2. Type Safety Check
Attempt to call .set() or .update() on the theme property from a component. The TypeScript compiler should throw an error because asReadonly() returns a Signal<T> rather than a WritableSignal<T>.
Failure Modes and Constraints
The Rendering Cycle Conflict
Updating a signal during the rendering process (e.g., inside a getter or a lifecycle hook that affects the current view) can trigger the ExpressionChangedAfterItHasBeenCheckedError. While Signals are synchronous, updating them during a check cycle still violates Angular's unidirectional data flow.
Resolution: Move the update to an effect() or schedule it asynchronously using setTimeout or Promise.resolve() if the update must happen as a side effect of rendering.
Infinite Loop Risks
Avoid using effect() to write to another signal that the first signal depends on. This creates a circular dependency that will crash the browser tab or trigger an infinite loop of updates.
When to Change the Design
Signals are not a total replacement for RxJS. You should revert to or integrate RxJS‑heavy patterns if your requirements include:
- Complex Async Orchestration: If you need to
debounceTimeuser input orswitchMapbetween multiple API calls. - Event Streams: For handling a sequence of events where the occurrence of the event is more important than the current value of the state.
- Multi‑stream Combining: When combining five or more asynchronous sources with complex timing requirements.
Rollback Strategy: If migrating a service to Signals causes instability, replace signal() with BehaviorSubject and computed() with map(). Update the components to use the async pipe to restore the previous Zone.js‑based detection behavior.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.