From Services to Signals:
The State Management Journey
Angular's state management has evolved through three distinct eras. Understanding this evolution is key to knowing why Signals are revolutionary.
Era 1: Services with BehaviorSubject (2016-2020)
The original pattern. Simple, but required manual subscription management and led to memory leaks if you forgot to unsubscribe.
// user.service.ts
@Injectable({ providedIn: 'root' })
export class UserService {
private userSubject = new BehaviorSubject(null);
user$ = this.userSubject.asObservable();
setUser(user: User) {
this.userSubject.next(user);
}
}
// component.ts
export class ProfileComponent implements OnInit, OnDestroy {
user: User | null = null;
private destroy$ = new Subject();
constructor(private userService: UserService) {}
ngOnInit() {
this.userService.user$
.pipe(takeUntil(this.destroy$))
.subscribe(user => this.user = user);
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}
Problems with BehaviorSubject
- ✖ Manual subscription management
- ✖ Easy to create memory leaks
- ✖ Boilerplate for every subscription
- ✖ No type safety on updates
- ✖ Difficult to compose derived state
Era 2: NgRx Store (2017-2024)
Redux pattern brought predictability and time-travel debugging. But at what cost? Actions, Reducers, Effects, Selectors—a simple counter required 5 files.
// 1. actions.ts
export const loadUser = createAction('[User] Load User');
export const loadUserSuccess = createAction(
'[User] Load User Success',
props<{ user: User }>()
);
// 2. reducer.ts
export const userReducer = createReducer(
initialState,
on(loadUserSuccess, (state, { user }) => ({ ...state, user }))
);
// 3. effects.ts
@Injectable()
export class UserEffects {
loadUser$ = createEffect(() =>
this.actions$.pipe(
ofType(loadUser),
switchMap(() => this.userService.getUser()),
map(user => loadUserSuccess({ user }))
)
);
}
// 4. selectors.ts
export const selectUser = createSelector(
selectUserState,
(state) => state.user
);
// 5. component.ts
export class ProfileComponent {
user$ = this.store.select(selectUser);
constructor(private store: Store) {}
loadUser() {
this.store.dispatch(loadUser());
}
}
02. Why Signals? The Reactive Revolution
Signals solve the fundamental problems of both approaches:
🎯 No Subscriptions
Signals are synchronous. No subscribe(), no async pipe, no memory leaks. Just read the value.
⚡ Fine-Grained Updates
Only components reading a changed signal re-render. No Zone.js walking the entire tree.
🔒 Type Safe
TypeScript infers types automatically. No manual type guards or casting needed.
// user.service.ts
@Injectable({ providedIn: 'root' })
export class UserService {
// ✅ Signal instead of BehaviorSubject
private _user = signal(null);
user = this._user.asReadonly();
setUser(user: User) {
this._user.set(user);
}
}
// component.ts
export class ProfileComponent {
// ✅ No subscription needed!
user = inject(UserService).user;
// ✅ Computed values are automatic
displayName = computed(() => {
const user = this.user();
return user ? \`${user.firstName} ${user.lastName}\` : 'Guest';
});
}
// template
Welcome, {{ displayName() }}
Email: {{ user()?.email }}
What Changed?
- ✔ No
ngOnInitorngOnDestroy - ✔ No
takeUntilorasync pipe - ✔ No manual subscription tracking
- ✔ Computed values update automatically
- ✔ Type-safe by default
Actions. Reducers. Selectors. Effects.
Enough.
NgRx implemented the Redux pattern perfectly. Too perfectly. For 90% of apps, the indirection was overkill.
The Signal Store (@ngrx/signals) is the answer. It combines the structure of NgRx with the simplicity of Signals, stripping away the ritualistic boilerplate.
04. The Signal Store: Functional Composition
The Signal Store is defined functionally. You compose features together. Need entities? Add withEntities. Need a computed value? Add withComputed.
import { signalStore, withState, withMethods, withComputed } from '@ngrx/signals';
import { computed } from '@angular/core';
export const CounterStore = signalStore(
{ providedIn: 'root' },
// 1. Define state
withState({ count: 0 }),
// 2. Add computed values
withComputed(({ count }) => ({
doubleCount: computed(() => count() * 2),
isEven: computed(() => count() % 2 === 0)
})),
// 3. Add methods
withMethods((store) => ({
increment() {
patchState(store, { count: store.count() + 1 });
},
decrement() {
patchState(store, { count: store.count() - 1 });
},
reset() {
patchState(store, { count: 0 });
}
}))
);
// Usage in component
@Component({
selector: 'app-counter',
standalone: true,
template: \`
Count: {{ store.count() }}
Double: {{ store.doubleCount() }}
Is Even: {{ store.isEven() }}
\`
})
export class CounterComponent {
store = inject(CounterStore);
}
import { signalStore, withState, withEntities, withMethods } from '@ngrx/signals';
import { setAllEntities, addEntity, updateEntity, removeEntity } from '@ngrx/signals/entities';
interface Book {
id: string;
title: string;
author: string;
}
export const BooksStore = signalStore(
{ providedIn: 'root' },
withState({ loading: false, query: '' }),
// ✅ Entities feature adds: entities, ids, entityMap
withEntities(),
withMethods((store, bookService = inject(BookService)) => ({
async loadByQuery(query: string) {
patchState(store, { loading: true, query });
const books = await bookService.search(query);
patchState(
store,
setAllEntities(books),
{ loading: false }
);
},
addBook(book: Book) {
patchState(store, addEntity(book));
},
updateBook(id: string, changes: Partial) {
patchState(store, updateEntity({ id, changes }));
},
removeBook(id: string) {
patchState(store, removeEntity(id));
}
}))
);
// Usage
@Component({
selector: 'app-books',
standalone: true,
template: \`
@if (store.loading()) {
Loading...
}
@for (book of store.entities(); track book.id) {
{{ book.title }}
by {{ book.author }}
}
\`
})
export class BooksComponent {
store = inject(BooksStore);
}
05. NgRx Classic vs Signal Store: Side by Side
Let's implement the same feature (a shopping cart) in both approaches:
Classic NgRx (5 files)
addToCart, removeFromCart, clearCart...
on(addToCart, (state, { item }) => ...)
selectCartItems, selectTotal...
saveToLocalStorage$, loadFromStorage$...
dispatch, select, async pipe...
Signal Store (1 file)
export const CartStore = signalStore(
{ providedIn: 'root' },
withEntities(),
withComputed(({ entities }) => ({
total: computed(() =>
entities().reduce((sum, item) =>
sum + item.price * item.quantity, 0
)
),
itemCount: computed(() =>
entities().reduce((sum, item) =>
sum + item.quantity, 0
)
)
})),
withMethods((store) => ({
addItem(item: CartItem) {
patchState(store, addEntity(item));
localStorage.setItem('cart',
JSON.stringify(store.entities())
);
},
removeItem(id: string) {
patchState(store, removeEntity(id));
},
clear() {
patchState(store, setAllEntities([]));
}
}))
);
Classic NgRx
- ✖ 4-5 files per feature
- ✖ String-based Action Types
- ✖ Effects allow hidden logic
- ✖ High learning curve
- ✖ Async pipe everywhere
Signal Store
- ✔ Single file definition
- ✔ Direct method calls
- ✔ Extensible via custom features
- ✔ Type-safe by default
- ✔ No subscriptions needed
06. Real-World Examples
Example 1: Todo App with Filtering
import { signalStore, withState, withComputed, withMethods } from '@ngrx/signals';
import { withEntities, addEntity, updateEntity, removeEntity } from '@ngrx/signals/entities';
interface Todo {
id: string;
title: string;
completed: boolean;
}
type Filter = 'all' | 'active' | 'completed';
export const TodoStore = signalStore(
{ providedIn: 'root' },
withState({ filter: 'all' as Filter }),
withEntities(),
withComputed(({ entities, filter }) => ({
// Filtered list based on current filter
filteredTodos: computed(() => {
const todos = entities();
const currentFilter = filter();
switch (currentFilter) {
case 'active':
return todos.filter(t => !t.completed);
case 'completed':
return todos.filter(t => t.completed);
default:
return todos;
}
}),
// Statistics
activeCount: computed(() =>
entities().filter(t => !t.completed).length
),
completedCount: computed(() =>
entities().filter(t => t.completed).length
)
})),
withMethods((store) => ({
addTodo(title: string) {
const todo: Todo = {
id: crypto.randomUUID(),
title,
completed: false
};
patchState(store, addEntity(todo));
},
toggleTodo(id: string) {
const todo = store.entityMap()[id];
if (todo) {
patchState(store, updateEntity({
id,
changes: { completed: !todo.completed }
}));
}
},
deleteTodo(id: string) {
patchState(store, removeEntity(id));
},
setFilter(filter: Filter) {
patchState(store, { filter });
},
clearCompleted() {
const completedIds = store.entities()
.filter(t => t.completed)
.map(t => t.id);
patchState(store, removeEntity(completedIds));
}
}))
);
// Component
@Component({
selector: 'app-todos',
standalone: true,
template: \`
@for (todo of store.filteredTodos(); track todo.id) {
{{ todo.title }}
}
@if (store.completedCount() > 0) {
}
\`
})
export class TodosComponent {
store = inject(TodoStore);
}
Example 2: User Authentication Store
interface User {
id: string;
email: string;
name: string;
role: 'admin' | 'user';
}
interface AuthState {
user: User | null;
token: string | null;
loading: boolean;
error: string | null;
}
export const AuthStore = signalStore(
{ providedIn: 'root' },
withState({
user: null,
token: null,
loading: false,
error: null
}),
withComputed(({ user }) => ({
isAuthenticated: computed(() => user() !== null),
isAdmin: computed(() => user()?.role === 'admin'),
userName: computed(() => user()?.name ?? 'Guest')
})),
withMethods((store, authService = inject(AuthService)) => ({
async login(email: string, password: string) {
patchState(store, { loading: true, error: null });
try {
const { user, token } = await authService.login(email, password);
patchState(store, {
user,
token,
loading: false
});
// Save to localStorage
localStorage.setItem('auth_token', token);
} catch (error) {
patchState(store, {
error: error.message,
loading: false
});
}
},
async register(email: string, password: string, name: string) {
patchState(store, { loading: true, error: null });
try {
const { user, token } = await authService.register(
email,
password,
name
);
patchState(store, {
user,
token,
loading: false
});
localStorage.setItem('auth_token', token);
} catch (error) {
patchState(store, {
error: error.message,
loading: false
});
}
},
logout() {
patchState(store, {
user: null,
token: null,
error: null
});
localStorage.removeItem('auth_token');
},
async loadFromToken() {
const token = localStorage.getItem('auth_token');
if (!token) return;
patchState(store, { loading: true });
try {
const user = await authService.verifyToken(token);
patchState(store, { user, token, loading: false });
} catch {
localStorage.removeItem('auth_token');
patchState(store, { loading: false });
}
}
}))
);
// Usage in component
@Component({
selector: 'app-header',
standalone: true,
template: \`
@if (auth.isAuthenticated()) {
Welcome, {{ auth.userName() }}
@if (auth.isAdmin()) {
Admin Panel
}
} @else {
Login
}
\`
})
export class HeaderComponent {
auth = inject(AuthStore);
}
07. Advanced Patterns
Custom Features
You can create reusable features that can be composed into any store:
// Create a reusable loading feature
export function withLoading() {
return signalStoreFeature(
withState({ loading: false }),
withMethods((store) => ({
setLoading(loading: boolean) {
patchState(store, { loading });
}
}))
);
}
// Create a reusable error handling feature
export function withErrorHandling() {
return signalStoreFeature(
withState({ error: null as string | null }),
withMethods((store) => ({
setError(error: string | null) {
patchState(store, { error });
},
clearError() {
patchState(store, { error: null });
}
}))
);
}
// Compose them into a store
export const ProductStore = signalStore(
{ providedIn: 'root' },
withEntities(),
withLoading(), // ✅ Reusable feature
withErrorHandling(), // ✅ Reusable feature
withMethods((store, productService = inject(ProductService)) => ({
async loadProducts() {
store.setLoading(true);
store.clearError();
try {
const products = await productService.getAll();
patchState(store, setAllEntities(products));
} catch (error) {
store.setError(error.message);
} finally {
store.setLoading(false);
}
}
}))
);
Optimistic Updates
export const PostStore = signalStore(
{ providedIn: 'root' },
withEntities(),
withMethods((store, postService = inject(PostService)) => ({
async likePost(postId: string) {
// 1. Optimistic update
const post = store.entityMap()[postId];
if (!post) return;
patchState(store, updateEntity({
id: postId,
changes: {
likes: post.likes + 1,
isLiked: true
}
}));
// 2. Make API call
try {
await postService.like(postId);
} catch (error) {
// 3. Rollback on error
patchState(store, updateEntity({
id: postId,
changes: {
likes: post.likes,
isLiked: false
}
}));
console.error('Failed to like post:', error);
}
}
}))
);
08. When to Use What?
Component State
Use plain signals in the component
Feature State
Use Signal Store
Global State
Use Classic NgRx (rare)
Decision Tree
- • State only used in one component? → Component signal
- • State shared across multiple components? → Signal Store
- • Need time-travel debugging or action logs? → Classic NgRx
- • Building a new app in 2026? → Signal Store
09. The Senior Engineer's Verdict
Is Classic NgRx Dead?
For new projects: Yes. The Signal Store covers 95% of use cases with 20% of the code.
However, the global Store (Redux style) still has a place in massive enterprise apps where complete state serialization, time-travel debugging, and distinct action logs are non-negotiable requirements purely for audit trails. But for managing component or feature state? Use Signal Store.
The Future: Angular is doubling down on Signals. The framework is being rewritten to use Signals internally. Learning Signal Store now is an investment in the future of Angular.
🎯 Conclusion
The Angular Signal Store represents a paradigm shift in how we think about state management. By combining the best aspects of NgRx's structure with the simplicity and performance of Signals, it offers a compelling alternative that reduces boilerplate while maintaining type safety and predictability.
Key Takeaways
- ✓ Signal Store reduces code by 75% compared to classic NgRx
- ✓ No subscriptions needed - Signals handle reactivity automatically
- ✓ Type-safe by default with full TypeScript inference
- ✓ Composable features make stores infinitely scalable
- ✓ Perfect for feature-level state management
When to Use
- • New Angular projects (2024+)
- • Feature-level state management
- • Apps prioritizing developer experience
- • Teams wanting less boilerplate
- • Projects using Angular Signals
As Angular continues to evolve with Signals at its core, the Signal Store positions itself as the future-proof choice for state management. Whether you're building a new application or gradually migrating from NgRx, the patterns and principles covered in this guide will serve you well in the modern Angular ecosystem.