Skip to main content

Signal Store (@ngrx/signals)

What is Signal Store?

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.

Why Use Signal Store?

  • 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

Signal Store vs NgRx Store

FeatureNgRx StoreSignal Store
StateImmutable (reducers)Signal-based
ActionsDispatch actionsCall methods directly
SelectorscreateSelectorComputed signals
Effects@ngrx/effectsrxMethod
BoilerplateHighLow
Learning curveSteepGentle
SizeLargeSmall
Use caseLarge appsSmall-medium apps

Installation

npm install @ngrx/signals

Basic Usage

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: [] });
}
}))
);

Store Features

withState

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>

withComputed

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[]

withMethods

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);
}
}))
);

Async Operations with rxMethod

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
})
})
)
)
}))
);

Complete Real-World Examples

Cart Store

import { Album } from '../models/album.model';

export interface CartItem {
album: Album;
quantity: number;
}

interface CartState {
items: CartItem[];
}

Album Store with HTTP

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 });
}
}))
);

Best Practices

  • ✅ 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)

Common Patterns

Pattern 1: Loading State

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 })
})
)
)
}))

Pattern 2: Optimistic Updates

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
}
})
)
)

Pattern 3: Pagination

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) });
}
}))


Project Reference

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