Angular’s introduction of Signals marks the most significant evolution in the framework’s history. By introducing a reactive primitive that tracks dependencies at a granular level, Angular is transitioning away from Zone.js to a zoneless reactive model.
In this guide, we will explore the core concepts of native Signals and how to implement clean, scalable state management using the new NgRx SignalStore.
1. Understanding Native Signals
A Signal is a wrapper around a value that can notify interested consumers when that value changes. Unlike RxJS Observables, Signals do not require subscriptions, are synchronous, and guarantee glitch-free execution.
import { signal, computed, effect } from '@angular/core';
// 1. Writable Signal
const count = signal(0);
// 2. Computed Signal (Read-Only, reactive dependency tracking)
const doubleCount = computed(() => count() * 2);
// 3. Effect (Runs asynchronously when dependencies change)
effect(() => {
console.log(`Count changed: ${count()} (Double: ${doubleCount()})`);
});
// Update value
count.set(5); // Logs: Count changed: 5 (Double: 10)
count.update(val => val + 1); // Logs: Count changed: 6 (Double: 12)
Because Signals track their dependencies dynamically at runtime, computed signals only recalculate when their underlying values change, making rendering incredibly fast.
2. Introducing NgRx SignalStore
While native signals are great, enterprise applications need a structured architecture for state management. NgRx SignalStore is a lightweight, fully type-safe state management solution built entirely on Signals.
Defining a SignalStore
Here is how to create a store for managing global authentication state:
import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
import { computed, inject } from '@angular/core';
import { AuthService } from './auth.service';
export interface AuthState {
user: User | null;
loading: boolean;
error: string | null;
}
const initialState: AuthState = {
user: null,
loading: false,
error: null
};
export const AuthStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
// Computed State
withComputed(({ user }) => ({
isAuthenticated: computed(() => user() !== null),
userRole: computed(() => user()?.role || 'guest')
})),
// Methods
withMethods((store, authService = inject(AuthService)) => ({
async login(credentials: Credentials) {
patchState(store, { loading: true, error: null });
try {
const user = await authService.authenticate(credentials);
patchState(store, { user, loading: false });
} catch (err: any) {
patchState(store, { error: err.message, loading: false });
}
},
logout() {
patchState(store, initialState);
}
}))
);
3. Consuming the SignalStore in Components
Integrating the store in an Angular component is clean and requires no RxJS selectors:
import { Component, inject } from '@angular/core';
import { AuthStore } from './auth.store';
@Component({
selector: 'app-user-profile',
template: `
@if (store.loading()) {
<p>Loading user profile...</p>
} @else if (store.isAuthenticated()) {
<h2>Welcome, {{ store.user()?.name }} (Role: {{ store.userRole() }})</h2>
<button (click)="store.logout()">Logout</button>
} @else {
<p>Please log in.</p>
}
`
})
export class UserProfileComponent {
readonly store = inject(AuthStore);
}
Benefits of the Signals Paradigm
- Zone-Free Efficiency: No global Change Detection cycles when store values change; Angular updates only the specific DOM nodes bound to the signal.
- Boilerplate Reduction: No actions, reducers, or selectors needed—just state, computed properties, and methods in a single class.
- Type Safety: NgRx SignalStore uses TypeScript’s advanced typing features to guarantee full compiler validation of state and computed selectors.