Skip to content

Signal Store (@ngrx/signals)

Signal Store is a lightweight state management library from @ngrx/signals that provides signal-based global state for Angular applications. It’s simpler than NgRx Store but more powerful than component-level signals.

  • ✅ Signal-based - Built on Angular signals
  • ✅ Lightweight - Much simpler than NgRx Store
  • ✅ Type-safe - Full TypeScript support
  • ✅ Composable - Build stores with features (withState, withMethods, withComputed)
  • ✅ RxJS interop - Use rxMethod for async operations
  • ✅ Tree-shakable - Only include what you use
  • ✅ No boilerplate - Clean, declarative API
Feature NgRx Store Signal Store
State Immutable (reducers) Signal-based
Actions Dispatch actions Call methods directly
Selectors createSelector Computed signals
Effects @ngrx/effects rxMethod
Boilerplate High Low
Learning curve Steep Gentle
Size Large Small
Use case Large apps Small-medium apps

Terminal window
npm install @ngrx/signals

import { signalStore, withState, withComputed, withMethods } from '@ngrx/signals';
import { computed } from '@angular/core';
// Define state interface
interface CartState {
items: CartItem[];
}
// Initial state
const initialState: CartState = {
items: []
};
// Create the store
export const CartStore = signalStore(
{ providedIn: 'root' }, // Make it global
withState(initialState), // Add state
withComputed((store) => ({
// Computed signals
itemCount: computed(() => store.items().length),
total: computed(() =>
store.items().reduce((sum, item) => sum + item.price, 0)
),
isEmpty: computed(() => store.items().length === 0)
})),
withMethods((store) => ({
// Actions
addItem(item: CartItem) {
patchState(store, {
items: [...store.items(), item]
});
},
removeItem(id: number) {
patchState(store, {
items: store.items().filter(i => i.id !== id)
});
},
clear() {
patchState(store, { items: [] });
}
}))
);

Add state to your store:

interface AlbumState {
albums: Album[];
loading: boolean;
error: string | null;
}
const initialState: AlbumState = {
albums: [],
loading: false,
error: null
};
export const AlbumStore = signalStore(
{ providedIn: 'root' },
withState(initialState) // Add state
);
// Access state
albumStore.albums() // Signal<Album[]>
albumStore.loading() // Signal<boolean>
albumStore.error() // Signal<string | null>

Add computed signals (derived state):

export const AlbumStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withComputed((store) => ({
// Computed from state
albumCount: computed(() => store.albums().length),
hasAlbums: computed(() => store.albums().length > 0),
isLoading: computed(() => store.loading()),
// Complex computations
rockAlbums: computed(() =>
store.albums().filter(a => a.tags.includes('Rock'))
),
averagePrice: computed(() => {
const albums = store.albums();
if (albums.length === 0) return 0;
return albums.reduce((sum, a) => sum + a.price, 0) / albums.length;
})
}))
);
// Access computed
albumStore.albumCount() // number
albumStore.rockAlbums() // Album[]

Add methods (actions) to modify state:

import { patchState } from '@ngrx/signals';
export const AlbumStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withMethods((store) => ({
// Simple state updates
setLoading(loading: boolean) {
patchState(store, { loading });
},
setError(error: string | null) {
patchState(store, { error });
},
// Add album
addAlbum(album: Album) {
patchState(store, {
albums: [...store.albums(), album]
});
},
// Update album
updateAlbum(updatedAlbum: Album) {
const albums = store.albums().map(a =>
a.id === updatedAlbum.id ? updatedAlbum : a
);
patchState(store, { albums });
},
// Remove album
removeAlbum(id: number) {
patchState(store, {
albums: store.albums().filter(a => a.id !== id)
});
},
// Clear all
reset() {
patchState(store, initialState);
}
}))
);

Use rxMethod for HTTP calls and async operations:

import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { inject } from '@angular/core';
import { AlbumService } from './album.service';
import { pipe, switchMap, tap } from 'rxjs';
interface AlbumState {
albums: Album[];
loading: boolean;
error: string | null;
}
export const AlbumStore = signalStore(
{ providedIn: 'root' },
withState<AlbumState>({
albums: [],
loading: false,
error: null
}),
withMethods((store, albumService = inject(AlbumService)) => ({
// Load all albums
loadAlbums: rxMethod<void>(
pipe(
tap(() => patchState(store, { loading: true, error: null })),
switchMap(() => albumService.findAll()),
tap({
next: (albums) => patchState(store, {
albums,
loading: false
}),
error: (error) => patchState(store, {
error: error.message,
loading: false
})
})
)
),
// Add album
addAlbum: rxMethod<Album>(
pipe(
tap(() => patchState(store, { loading: true })),
switchMap((album) => albumService.save(album)),
tap({
next: (newAlbum) => patchState(store, {
albums: [...store.albums(), newAlbum],
loading: false
}),
error: (error) => patchState(store, {
error: error.message,
loading: false
})
})
)
),
// Delete album
deleteAlbum: rxMethod<number>(
pipe(
tap(() => patchState(store, { loading: true })),
switchMap((id) => albumService.delete(id).pipe(
map(() => id)
)),
tap({
next: (id) => patchState(store, {
albums: store.albums().filter(a => a.id !== id),
loading: false
}),
error: (error) => patchState(store, {
error: error.message,
loading: false
})
})
)
)
}))
);

import { Album } from '../models/album.model';
export interface CartItem {
album: Album;
quantity: number;
}
interface CartState {
items: CartItem[];
}

import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { inject, computed } from '@angular/core';
import { AlbumService } from '../services/album.service';
import { Album } from '../models/album.model';
import { pipe, switchMap, tap, map, concatMap, finalize } from 'rxjs';
interface AlbumState {
albums: Album[];
selectedAlbum: Album | null;
loading: boolean;
loaded: boolean;
error: string | null;
}
const initialState: AlbumState = {
albums: [],
selectedAlbum: null,
loading: false,
loaded: false,
error: null
};
export const AlbumStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withComputed((store) => ({
albumCount: computed(() => store.albums().length),
hasAlbums: computed(() => store.albums().length > 0),
isLoading: computed(() => store.loading()),
isLoaded: computed(() => store.loaded()),
// Filtered lists
highlightedAlbums: computed(() =>
store.albums().filter(a => a.highlighted)
),
albumsByTag: computed(() => (tag: string) =>
store.albums().filter(a => a.tags.includes(tag))
)
})),
withMethods((store, albumService = inject(AlbumService)) => ({
// Load all albums
loadAlbums: rxMethod<void>(
pipe(
tap(() => patchState(store, { loading: true, error: null })),
switchMap(() => albumService.findAll()),
tap({
next: (albums) => patchState(store, {
albums,
loading: false,
loaded: true
}),
error: (error) => patchState(store, {
error: error.message,
loading: false
})
})
)
),
// Add album
addAlbum: rxMethod<Album>(
pipe(
tap(() => patchState(store, { loading: true })),
switchMap((album) => albumService.save(album)),
tap({
next: (newAlbum) => patchState(store, {
albums: [...store.albums(), newAlbum],
loading: false
}),
error: (error) => patchState(store, {
error: error.message,
loading: false
})
})
)
),
// Update album
updateAlbum: rxMethod<Album>(
pipe(
tap(() => patchState(store, { loading: true })),
switchMap((album) => albumService.update(album)),
tap({
next: (updatedAlbum) => {
const albums = store.albums().map(a =>
a.id === updatedAlbum.id ? updatedAlbum : a
);
patchState(store, { albums, loading: false });
},
error: (error) => patchState(store, {
error: error.message,
loading: false
})
})
)
),
// Delete album
deleteAlbum: rxMethod<number>(
pipe(
tap(() => patchState(store, { loading: true, error: null })),
concatMap((id) =>
albumService.delete(id).pipe(
map(() => id),
finalize(() => patchState(store, { loading: false }))
)
),
tap({
next: (id) => patchState(store, {
albums: store.albums().filter(a => a.id !== id)
}),
error: (error) => patchState(store, {
error: error?.message ?? 'Delete failed'
})
})
)
),
// Select album
selectAlbum(album: Album | null) {
patchState(store, { selectedAlbum: album });
},
// Clear error
clearError() {
patchState(store, { error: null });
}
}))
);

  • ✅ Use signalStore() for global state
  • ✅ Define state interface and initial state
  • ✅ Use withState() for state properties
  • ✅ Use withComputed() for derived values
  • ✅ Use withMethods() for actions
  • ✅ Use rxMethod() for async operations
  • ✅ Always update state immutably with patchState()
  • ✅ Keep stores focused (one responsibility)
  • ✅ Inject services in withMethods()
  • ❌ Don’t mutate state directly
  • ❌ Don’t create too many stores (prefer composition)
  • ❌ Don’t put logic in computed (keep it pure)

withMethods((store, service = inject(Service)) => ({
loadData: rxMethod<void>(
pipe(
tap(() => patchState(store, { loading: true, error: null })),
switchMap(() => service.getData()),
tap({
next: (data) => patchState(store, { data, loading: false }),
error: (error) => patchState(store, { error: error.message, loading: false })
})
)
)
}))
deleteItem: rxMethod<number>(
pipe(
// Optimistic update
tap((id) => patchState(store, {
items: store.items().filter(i => i.id !== id)
})),
switchMap((id) => service.delete(id)),
tap({
error: (error) => {
// Rollback on error
console.error('Delete failed, restoring item');
this.loadItems(); // Reload from server
}
})
)
)
interface State {
items: Item[];
page: number;
pageSize: number;
total: number;
}
withComputed((store) => ({
paginatedItems: computed(() => {
const start = store.page() * store.pageSize();
const end = start + store.pageSize();
return store.items().slice(start, end);
}),
totalPages: computed(() =>
Math.ceil(store.total() / store.pageSize())
)
})),
withMethods((store) => ({
nextPage() {
patchState(store, { page: store.page() + 1 });
},
previousPage() {
patchState(store, { page: Math.max(0, store.page() - 1) });
}
}))


See this pattern in action:

  • Cart Store: src/app/store/cart.store.ts
  • Album Store: src/app/store/album.store.ts
  • Component: src/app/components/albums/album-list/album-list.component.ts
  • Learning Path: Day 3, Module 3.8 - Signal Store

Last Updated: December 2024 Package: @ngrx/signals Angular Version: 16+ Status: Recommended for modern Angular apps