NgRx Signal Store: Real-World Patterns (Časť 2)
8 min
NgRx Signal Store: Real-World Patterns
V tejto časti sa pozrieme na praktické patterns, ktoré budete používať v reálnych projektoch.
Kapitola 3: Real-World Patterns
Pattern 1: Store with HTTP
typescript
// stores/todo.store.ts
import { computed, inject } from '@angular/core';
import { signalStore, withState, withComputed, withMethods } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { pipe, switchMap, tap } from 'rxjs';
interface Todo {
id: string;
title: string;
completed: boolean;
}
interface TodosState {
todos: Todo[];
loading: boolean;
error: string | null;
}
export const TodoStore = signalStore(
{ providedIn: 'root' },
withState<TodosState>({
todos: [],
loading: false,
error: null
}),
withComputed(({ todos }) => ({
completedTodos: computed(() => todos().filter(t => t.completed)),
activeTodos: computed(() => todos().filter(t => !t.completed)),
stats: computed(() => {
const all = todos();
return {
total: all.length,
completed: all.filter(t => t.completed).length,
active: all.filter(t => !t.completed).length
};
})
})),
withMethods((store, http = inject(HttpClient)) => ({
// RxMethod for reactive HTTP calls
loadTodos: rxMethod<void>(
pipe(
tap(() => patchState(store, { loading: true, error: null })),
switchMap(() => http.get<Todo[]>('/api/todos')),
tap({
next: (todos) => patchState(store, { todos, loading: false }),
error: (error) => patchState(store, {
error: 'Failed to load todos',
loading: false
})
})
)
),
async addTodo(title: string) {
try {
const newTodo = await http.post<Todo>('/api/todos', { title })
.toPromise();
if (newTodo) {
patchState(store, {
todos: [...store.todos(), newTodo]
});
}
} catch (err) {
patchState(store, { error: 'Failed to add todo' });
}
},
async toggleTodo(id: string) {
const todo = store.todos().find(t => t.id === id);
if (!todo) return;
// Optimistic update
const updatedTodos = store.todos().map(t =>
t.id === id ? { ...t, completed: !t.completed } : t
);
patchState(store, { todos: updatedTodos });
try {
await http.patch(`/api/todos/${id}`, {
completed: !todo.completed
}).toPromise();
} catch (err) {
// Revert on error
patchState(store, {
todos: store.todos(),
error: 'Failed to update todo'
});
}
},
async deleteTodo(id: string) {
const originalTodos = store.todos();
// Optimistic delete
patchState(store, {
todos: originalTodos.filter(t => t.id !== id)
});
try {
await http.delete(`/api/todos/${id}`).toPromise();
} catch (err) {
// Revert on error
patchState(store, {
todos: originalTodos,
error: 'Failed to delete todo'
});
}
}
}))
);Usage:
typescript
@Component({
selector: 'app-todos',
standalone: true,
template: `
<div>
@if (store.loading()) {
<p>Loading...</p>
}
@if (store.error(); as error) {
<p class="error">{{ error }}</p>
}
<div class="stats">
<p>Total: {{ store.stats().total }}</p>
<p>Active: {{ store.stats().active }}</p>
<p>Completed: {{ store.stats().completed }}</p>
</div>
<ul>
@for (todo of store.todos(); track todo.id) {
<li>
<input
type="checkbox"
[checked]="todo.completed"
(change)="store.toggleTodo(todo.id)"
/>
<span [class.completed]="todo.completed">
{{ todo.title }}
</span>
<button (click)="store.deleteTodo(todo.id)">
Delete
</button>
</li>
}
</ul>
</div>
`
})
export class TodosComponent implements OnInit {
readonly store = inject(TodoStore);
ngOnInit() {
this.store.loadTodos();
}
}Pattern 2: Store with LocalStorage
typescript
// stores/settings.store.ts
import { effect } from '@angular/core';
import { signalStore, withState, withComputed, withMethods, withHooks } from '@ngrx/signals';
import { computed } from '@angular/core';
interface Settings {
theme: 'light' | 'dark';
language: 'en' | 'sk';
notifications: boolean;
}
const STORAGE_KEY = 'app_settings';
function loadSettings(): Settings {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : {
theme: 'light',
language: 'en',
notifications: true
};
}
export const SettingsStore = signalStore(
{ providedIn: 'root' },
withState<Settings>(loadSettings()),
withComputed(({ theme, language }) => ({
isDarkTheme: computed(() => theme() === 'dark'),
currentLanguage: computed(() => language())
})),
withMethods((store) => ({
setTheme(theme: 'light' | 'dark') {
patchState(store, { theme });
},
setLanguage(language: 'en' | 'sk') {
patchState(store, { language });
},
toggleNotifications() {
patchState(store, {
notifications: !store.notifications()
});
}
})),
// Use hooks for side effects
withHooks({
onInit(store) {
// Auto-save to localStorage on any change
effect(() => {
const settings: Settings = {
theme: store.theme(),
language: store.language(),
notifications: store.notifications()
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
});
}
})
);Pattern 3: Store with Entities
typescript
// stores/products.store.ts
import { signalStore, withState, withComputed, withMethods } from '@ngrx/signals';
import { addEntity, removeEntity, updateEntity, withEntities } from '@ngrx/signals/entities';
import { computed } from '@angular/core';
interface Product {
id: string;
name: string;
price: number;
category: string;
}
export const ProductsStore = signalStore(
{ providedIn: 'root' },
// Use entity management
withEntities<Product>(),
withState({
selectedId: null as string | null,
filter: ''
}),
withComputed((store) => ({
filteredProducts: computed(() => {
const filter = store.filter().toLowerCase();
return store.entities().filter(p =>
p.name.toLowerCase().includes(filter)
);
}),
selectedProduct: computed(() => {
const id = store.selectedId();
return id ? store.entityMap()[id] : null;
}),
productsByCategory: computed(() => {
const products = store.entities();
const grouped = new Map<string, Product[]>();
products.forEach(p => {
const list = grouped.get(p.category) ?? [];
list.push(p);
grouped.set(p.category, list);
});
return grouped;
})
})),
withMethods((store) => ({
addProduct(product: Product) {
patchState(store, addEntity(product));
},
updateProduct(id: string, changes: Partial<Product>) {
patchState(store, updateEntity({ id, changes }));
},
deleteProduct(id: string) {
patchState(store, removeEntity(id));
},
selectProduct(id: string) {
patchState(store, { selectedId: id });
},
setFilter(filter: string) {
patchState(store, { filter });
}
}))
);Pattern 4: Derived Stores (Composition)
typescript
// Base store
export const ProductStore = signalStore(
{ providedIn: 'root' },
withEntities<Product>(),
withMethods((store) => ({
loadProducts(products: Product[]) {
patchState(store, setAllEntities(products));
}
}))
);
// Derived store that depends on ProductStore
export const CartStore = signalStore(
{ providedIn: 'root' },
withState({
items: [] as Array<{ productId: string; quantity: number }>
}),
withComputed((store, productStore = inject(ProductStore)) => ({
// Join cart items with products
itemsWithDetails: computed(() => {
const products = productStore.entityMap();
return store.items().map(item => {
const product = products[item.productId];
return {
...item,
product,
subtotal: product ? product.price * item.quantity : 0
};
});
}),
total: computed(() => {
const items = store.itemsWithDetails();
return items.reduce((sum, item) => sum + item.subtotal, 0);
})
})),
withMethods((store) => ({
addToCart(productId: string, quantity: number = 1) {
const items = store.items();
const existing = items.find(i => i.productId === productId);
if (existing) {
patchState(store, {
items: items.map(i =>
i.productId === productId
? { ...i, quantity: i.quantity + quantity }
: i
)
});
} else {
patchState(store, {
items: [...items, { productId, quantity }]
});
}
},
removeFromCart(productId: string) {
patchState(store, {
items: store.items().filter(i => i.productId !== productId)
});
}
}))
);Kapitola 4: Custom Features
Custom Feature: withUndo
typescript
// features/with-undo.ts
import { computed, Signal } from '@angular/core';
import { signalStoreFeature, withComputed, withMethods, withState } from '@ngrx/signals';
interface UndoState<T> {
history: T[];
currentIndex: number;
}
export function withUndo<T>(initialState: T) {
return signalStoreFeature(
withState<UndoState<T>>({
history: [initialState],
currentIndex: 0
}),
withComputed((store) => ({
currentState: computed(() =>
store.history()[store.currentIndex()]
),
canUndo: computed(() => store.currentIndex() > 0),
canRedo: computed(() =>
store.currentIndex() < store.history().length - 1
)
})),
withMethods((store) => ({
addState(state: T) {
const history = store.history().slice(0, store.currentIndex() + 1);
patchState(store, {
history: [...history, state],
currentIndex: history.length
});
},
undo() {
if (store.canUndo()) {
patchState(store, {
currentIndex: store.currentIndex() - 1
});
}
},
redo() {
if (store.canRedo()) {
patchState(store, {
currentIndex: store.currentIndex() + 1
});
}
}
}))
);
}
// Usage
export const EditorStore = signalStore(
withUndo({ content: '', cursor: 0 }),
withMethods((store) => ({
updateContent(content: string, cursor: number) {
store.addState({ content, cursor });
}
}))
);Custom Feature: withPagination
typescript
// features/with-pagination.ts
import { computed } from '@angular/core';
import { signalStoreFeature, withComputed, withMethods, withState } from '@ngrx/signals';
interface PaginationState {
page: number;
pageSize: number;
total: number;
}
export function withPagination(pageSize: number = 20) {
return signalStoreFeature(
withState<PaginationState>({
page: 1,
pageSize,
total: 0
}),
withComputed((store) => ({
totalPages: computed(() =>
Math.ceil(store.total() / store.pageSize())
),
hasNextPage: computed(() =>
store.page() < store.totalPages()
),
hasPrevPage: computed(() =>
store.page() > 1
),
offset: computed(() =>
(store.page() - 1) * store.pageSize()
)
})),
withMethods((store) => ({
nextPage() {
if (store.hasNextPage()) {
patchState(store, { page: store.page() + 1 });
}
},
prevPage() {
if (store.hasPrevPage()) {
patchState(store, { page: store.page() - 1 });
}
},
setPage(page: number) {
patchState(store, { page });
},
setPageSize(pageSize: number) {
patchState(store, { pageSize, page: 1 });
},
setTotal(total: number) {
patchState(store, { total });
}
}))
);
}
// Usage
export const ProductListStore = signalStore(
{ providedIn: 'root' },
withEntities<Product>(),
withPagination(20),
withMethods((store, http = inject(HttpClient)) => ({
async loadPage() {
const response = await http.get<PaginatedResponse<Product>>(
'/api/products',
{
params: {
offset: store.offset().toString(),
limit: store.pageSize().toString()
}
}
).toPromise();
if (response) {
patchState(store, setAllEntities(response.items));
store.setTotal(response.total);
}
}
}))
);Custom Feature: withLoading
typescript
// features/with-loading.ts
import { signalStoreFeature, withMethods, withState } from '@ngrx/signals';
interface LoadingState {
loading: boolean;
error: string | null;
}
export function withLoading() {
return signalStoreFeature(
withState<LoadingState>({
loading: false,
error: null
}),
withMethods((store) => ({
setLoading(loading: boolean) {
patchState(store, { loading, error: null });
},
setError(error: string) {
patchState(store, { loading: false, error });
},
clearError() {
patchState(store, { error: null });
}
}))
);
}
// Usage
export const TodoStore = signalStore(
{ providedIn: 'root' },
withEntities<Todo>(),
withLoading(), // Add loading feature
withMethods((store, http = inject(HttpClient)) => ({
async loadTodos() {
store.setLoading(true);
try {
const todos = await http.get<Todo[]>('/api/todos').toPromise();
patchState(store, setAllEntities(todos ?? []));
} catch (err) {
store.setError('Failed to load todos');
}
}
}))
);V poslednej časti sa pozrieme na Provider Comparison, Testing, Best Practices a Migration z classic NgRx.