Príchod mesiáša: NgRx Signal Store - Úvod a porovnanie (Časť 1)
13 min
Príchod mesiáša: NgRx Signal Store
"A Signal was sent from the heavens..." ✨
Angular developeri roky trpeli. Redux pattern? Boilerplate hell. Component Store? Lepší, ale stále... niečo chýbalo.
A potom prišiel on: NgRx Signal Store.
Prolog: State management apokalypsa (krátky historický exkurz)
typescript
// 2016: Redux era začína
const pain = {
actions: "Must define every action",
reducers: "Must create pure functions",
selectors: "Must memoize everything",
effects: "Must handle side effects",
boilerplate: "MAXIMUM OVERLOAD"
};
console.log("Please save us...");Timeline of suffering:
2016: NgRx Store (Redux)
Developer: "Wow, predictable state!"
*3 months later*
Developer: "I just wanted to add a counter..."
*looks at 5 files*
Developer: "..."
2020: NgRx Component Store
Developer: "Finally, less boilerplate!"
*uses it*
Developer: "Better, but still RxJS hell..."
2023: Signals arrive
Developer: "Aha! Simple reactive primitives!"
*tries to build app*
Developer: "Wait, I need more structure..."
2024: NgRx Signal Store
Developer: "IS THIS THE ONE?!"
*uses it*
Developer: "...yes. YES. THIS IS IT!"Kapitola 1: The Boilerplate Horror Show
Exhibit A: NgRx Store (Redux pattern)
Task: Add counter
Code required:
typescript
// 1. actions/counter.actions.ts (35 lines)
import { createAction, props } from '@ngrx/store';
export const increment = createAction('[Counter] Increment');
export const decrement = createAction('[Counter] Decrement');
export const reset = createAction('[Counter] Reset');
export const setCount = createAction(
'[Counter] Set Count',
props<{ count: number }>()
);
// 2. reducers/counter.reducer.ts (40 lines)
import { createReducer, on } from '@ngrx/store';
import * as CounterActions from './counter.actions';
export interface CounterState {
count: number;
lastUpdate: Date | null;
}
export const initialState: CounterState = {
count: 0,
lastUpdate: null
};
export const counterReducer = createReducer(
initialState,
on(CounterActions.increment, state => ({
...state,
count: state.count + 1,
lastUpdate: new Date()
})),
on(CounterActions.decrement, state => ({
...state,
count: state.count - 1,
lastUpdate: new Date()
})),
on(CounterActions.reset, state => ({
...state,
count: 0,
lastUpdate: new Date()
})),
on(CounterActions.setCount, (state, { count }) => ({
...state,
count,
lastUpdate: new Date()
}))
);
// 3. selectors/counter.selectors.ts (30 lines)
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { CounterState } from '../reducers/counter.reducer';
export const selectCounterState =
createFeatureSelector<CounterState>('counter');
export const selectCount = createSelector(
selectCounterState,
state => state.count
);
export const selectLastUpdate = createSelector(
selectCounterState,
state => state.lastUpdate
);
export const selectIsPositive = createSelector(
selectCount,
count => count > 0
);
// 4. app.config.ts (register reducer)
import { provideStore } from '@ngrx/store';
import { counterReducer } from './reducers/counter.reducer';
export const appConfig: ApplicationConfig = {
providers: [
provideStore({ counter: counterReducer })
]
};
// 5. counter.component.ts (50 lines)
import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import * as CounterActions from './actions/counter.actions';
import * as CounterSelectors from './selectors/counter.selectors';
@Component({
selector: 'app-counter',
template: `
<div>
<h2>Count: {{ count$ | async }}</h2>
<p>Last update: {{ lastUpdate$ | async | date }}</p>
<p>Is positive: {{ isPositive$ | async }}</p>
<button (click)="increment()">+</button>
<button (click)="decrement()">-</button>
<button (click)="reset()">Reset</button>
</div>
`
})
export class CounterComponent {
count$: Observable<number>;
lastUpdate$: Observable<Date | null>;
isPositive$: Observable<boolean>;
constructor(private store: Store) {
this.count$ = this.store.select(CounterSelectors.selectCount);
this.lastUpdate$ = this.store.select(CounterSelectors.selectLastUpdate);
this.isPositive$ = this.store.select(CounterSelectors.selectIsPositive);
}
increment() {
this.store.dispatch(CounterActions.increment());
}
decrement() {
this.store.dispatch(CounterActions.decrement());
}
reset() {
this.store.dispatch(CounterActions.reset());
}
}
// Total: 5 files, ~155 lines of code
// Developer sanity: 📉Developer reaction:
Hour 1: "Ok, I'll create actions..."
Hour 2: "Now reducers..."
Hour 3: "Selectors..."
Hour 4: "Wait, I just wanted a counter..."
Hour 5: *existential crisis*Exhibit B: NgRx Component Store
Task: Same counter
Code required:
typescript
// 1. counter.store.ts (60 lines)
import { Injectable } from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';
export interface CounterState {
count: number;
lastUpdate: Date | null;
}
@Injectable()
export class CounterStore extends ComponentStore<CounterState> {
constructor() {
super({ count: 0, lastUpdate: null });
}
// Selectors
readonly count$ = this.select(state => state.count);
readonly lastUpdate$ = this.select(state => state.lastUpdate);
readonly isPositive$ = this.select(
this.count$,
count => count > 0
);
// Updaters
readonly increment = this.updater((state) => ({
...state,
count: state.count + 1,
lastUpdate: new Date()
}));
readonly decrement = this.updater((state) => ({
...state,
count: state.count - 1,
lastUpdate: new Date()
}));
readonly reset = this.updater((state) => ({
...state,
count: 0,
lastUpdate: new Date()
}));
readonly setCount = this.updater((state, count: number) => ({
...state,
count,
lastUpdate: new Date()
}));
}
// 2. counter.component.ts (35 lines)
import { Component } from '@angular/core';
import { CounterStore } from './counter.store';
@Component({
selector: 'app-counter',
template: `
<div>
<h2>Count: {{ store.count$ | async }}</h2>
<p>Last update: {{ store.lastUpdate$ | async | date }}</p>
<p>Is positive: {{ store.isPositive$ | async }}</p>
<button (click)="store.increment()">+</button>
<button (click)="store.decrement()">-</button>
<button (click)="store.reset()">Reset</button>
</div>
`,
providers: [CounterStore]
})
export class CounterComponent {
constructor(public store: CounterStore) {}
}
// Total: 2 files, ~95 lines
// Better! But still... RxJS, async pipes, observables everywhereDeveloper reaction:
"Much better than Redux!"
"But still class-based..."
"Still need to understand RxJS..."
"Still using observables everywhere..."
"async pipe everywhere..."
"There must be a better way..."Exhibit C: NgRx Signal Store (THE MESSIAH)
Task: Same counter
Code required:
typescript
// 1. counter.store.ts (ALL IN ONE!)
import { signalStore, withState, withComputed, withMethods } from '@ngrx/signals';
import { computed } from '@angular/core';
export const CounterStore = signalStore(
{ providedIn: 'root' }, // or component level
// State
withState({
count: 0,
lastUpdate: null as Date | null
}),
// Computed (derived state)
withComputed((store) => ({
isPositive: computed(() => store.count() > 0),
doubleCount: computed(() => store.count() * 2)
})),
// Methods (actions)
withMethods((store) => ({
increment() {
store.update(state => ({
count: state.count + 1,
lastUpdate: new Date()
}));
},
decrement() {
store.update(state => ({
count: state.count - 1,
lastUpdate: new Date()
}));
},
reset() {
store.update({ count: 0, lastUpdate: new Date() });
},
setCount(count: number) {
store.update({ count, lastUpdate: new Date() });
}
}))
);
// 2. counter.component.ts
import { Component, inject } from '@angular/core';
import { CounterStore } from './counter.store';
@Component({
selector: 'app-counter',
template: `
<div>
<h2>Count: {{ counter.count() }}</h2>
<p>Double: {{ counter.doubleCount() }}</p>
<p>Last update: {{ counter.lastUpdate() | date }}</p>
<p>Is positive: {{ counter.isPositive() }}</p>
<button (click)="counter.increment()">+</button>
<button (click)="counter.decrement()">-</button>
<button (click)="counter.reset()">Reset</button>
</div>
`
})
export class CounterComponent {
counter = inject(CounterStore);
// That's it. No constructor. No observables. Just signals.
}
// Total: 2 files, ~50 lines
// No actions, no reducers, no selectors files
// No observables, no async pipes
// Pure reactive bliss ✨Developer reaction:
"Wait... that's it?"
"No actions file?"
"No reducer?"
"No selectors?"
"No async pipes?!"
"THIS IS THE ONE!" 🎉Kapitola 2: The Great Comparison Matrix
Round 1: NgRx Store vs Signal Store
| Aspekt | NgRx Store (Redux) | Signal Store | Winner |
|---|---|---|---|
| Lines of code | ~155 (5 files) | ~50 (2 files) | 🏆 Signal Store |
| Boilerplate | MAXIMUM | Minimal | 🏆 Signal Store |
| Learning curve | Steep AF | Gentle slope | 🏆 Signal Store |
| Observables | Everywhere | Optional | 🏆 Signal Store |
| async pipes | Required | Not needed | 🏆 Signal Store |
| Type safety | Good | Excellent | 🏆 Signal Store |
| DevTools | Redux DevTools | TBD (coming) | 🏆 NgRx Store |
| Time travel | Yes | Not yet | 🏆 NgRx Store |
| Global state | Natural | Natural | 🤝 Tie |
| Debugging | Excellent | Good | 🏆 NgRx Store |
| Performance | Good | Better | 🏆 Signal Store |
| Bundle size | Larger | Smaller | 🏆 Signal Store |
Verdict:
- NgRx Store: Enterprise behemoth (use for complex apps needing Redux DevTools)
- Signal Store: Modern minimalist (use for everything else)
Round 2: Component Store vs Signal Store
| Aspekt | Component Store | Signal Store | Winner |
|---|---|---|---|
| Syntax | Class-based | Functional | 🏆 Signal Store |
| Composability | Limited (single inheritance) | Excellent (features) | 🏆 Signal Store |
| RxJS required | Yes | Optional | 🏆 Signal Store |
| Local state | Perfect | Perfect | 🤝 Tie |
| Modularity | OK | Excellent | 🏆 Signal Store |
| Custom features | Hard | Easy | 🏆 Signal Store |
| Migration path | Easy to Signal Store | N/A | 🏆 Component Store |
Real-world příklad:
typescript
// Component Store: Want Immer + Entity features?
// ❌ Can't extend both (single inheritance)
class MyStore extends ComponentStore<State> {
// Pick one or hack around it
}
// Signal Store: Want Immer + Entity + Custom features?
// ✅ Just compose them
export const MyStore = signalStore(
withState(initialState),
withEntities<Product>(),
withImmer(),
withMyCustomFeature(),
withAnotherFeature()
// Compose infinitely!
);Round 3: Pure Signals vs Signal Store
| Aspekt | Pure Signals | Signal Store | Winner |
|---|---|---|---|
| Simplicity | Maximum | High | 🏆 Pure Signals |
| Structure | DIY | Built-in | 🏆 Signal Store |
| Team work | Chaotic | Organized | 🏆 Signal Store |
| Scalability | Limited | Excellent | 🏆 Signal Store |
| Side effects | Manual | rxMethod | 🏆 Signal Store |
| Best for | Tiny apps | Real apps | Depends |
Rule of thumb:
typescript
// Use pure Signals
if (appSize === 'tiny' && developers === 1) {
return useSignals();
}
// Use Signal Store
if (appSize >= 'small' && developers >= 2) {
return useSignalStore();
}
// Use NgRx Store (Redux)
if (appSize === 'massive' && needsTimeTravel) {
return useNgrxStore(); // But consider carefully
}Kapitola 3: Real-World Battle: Todo App
Ukážme reálnu aplikáciu v rôznych štýloch.
Version A: NgRx Store (Redux Hell)
typescript
// 1. actions/todo.actions.ts
export const loadTodos = createAction('[Todo] Load Todos');
export const loadTodosSuccess = createAction(
'[Todo] Load Todos Success',
props<{ todos: Todo[] }>()
);
export const loadTodosFailure = createAction(
'[Todo] Load Todos Failure',
props<{ error: string }>()
);
export const addTodo = createAction('[Todo] Add', props<{ text: string }>());
export const toggleTodo = createAction('[Todo] Toggle', props<{ id: number }>());
export const deleteTodo = createAction('[Todo] Delete', props<{ id: number }>());
// ... 8 more actions
// 2. reducers/todo.reducer.ts (100+ lines)
export interface TodoState {
todos: Todo[];
loading: boolean;
error: string | null;
filter: TodoFilter;
}
export const todoReducer = createReducer(
initialState,
on(loadTodos, state => ({ ...state, loading: true })),
on(loadTodosSuccess, (state, { todos }) => ({
...state,
todos,
loading: false
})),
// ... 15 more reducers
);
// 3. effects/todo.effects.ts (80 lines)
@Injectable()
export class TodoEffects {
loadTodos$ = createEffect(() =>
this.actions$.pipe(
ofType(loadTodos),
switchMap(() =>
this.todoService.getTodos().pipe(
map(todos => loadTodosSuccess({ todos })),
catchError(error => of(loadTodosFailure({ error: error.message })))
)
)
)
);
// ... 5 more effects
}
// 4. selectors/todo.selectors.ts (60 lines)
export const selectTodoState = createFeatureSelector<TodoState>('todos');
export const selectAllTodos = createSelector(selectTodoState, state => state.todos);
export const selectLoading = createSelector(selectTodoState, state => state.loading);
// ... 10 more selectors
// 5. Component (50 lines)
export class TodoComponent {
todos$ = this.store.select(selectFilteredTodos);
loading$ = this.store.select(selectLoading);
// ... observable hell
addTodo(text: string) {
this.store.dispatch(addTodo({ text }));
}
// ... dispatch hell
}
// Total: 5 files, 400+ lines
// Mental health: 💀Version B: Component Store (Better)
typescript
// 1. todo.store.ts (150 lines)
@Injectable()
export class TodoStore extends ComponentStore<TodoState> {
// Selectors
readonly todos$ = this.select(state => state.todos);
readonly loading$ = this.select(state => state.loading);
readonly filteredTodos$ = this.select(
this.todos$,
this.select(state => state.filter),
(todos, filter) => todos.filter(/* filter logic */)
);
// Updaters
readonly addTodo = this.updater((state, text: string) => ({
...state,
todos: [...state.todos, { id: Date.now(), text, done: false }]
}));
readonly toggleTodo = this.updater((state, id: number) => ({
...state,
todos: state.todos.map(t => t.id === id ? { ...t, done: !t.done } : t)
}));
// Effects
readonly loadTodos = this.effect((trigger$: Observable<void>) =>
trigger$.pipe(
tap(() => this.patchState({ loading: true })),
switchMap(() =>
this.todoService.getTodos().pipe(
tapResponse(
todos => this.patchState({ todos, loading: false }),
error => this.patchState({ error: error.message, loading: false })
)
)
)
)
);
}
// 2. Component (30 lines)
export class TodoComponent {
constructor(public todoStore: TodoStore) {
this.todoStore.loadTodos();
}
}
// Total: 2 files, 180 lines
// Better, but still RxJS heavyVersion C: Signal Store (THE WAY)
typescript
// 1. todo.store.ts (ONE FILE, ~100 lines)
import { signalStore, withState, withComputed, withMethods } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { pipe, switchMap, tap } from 'rxjs';
type TodoFilter = 'all' | 'active' | 'completed';
export const TodoStore = signalStore(
{ providedIn: 'root' },
// State
withState({
todos: [] as Todo[],
loading: false,
error: null as string | null,
filter: 'all' as TodoFilter
}),
// Computed (auto-updates)
withComputed((store) => ({
filteredTodos: computed(() => {
const todos = store.todos();
const filter = store.filter();
switch (filter) {
case 'active': return todos.filter(t => !t.done);
case 'completed': return todos.filter(t => t.done);
default: return todos;
}
}),
activeCount: computed(() =>
store.todos().filter(t => !t.done).length
),
completedCount: computed(() =>
store.todos().filter(t => t.done).length
)
})),
// Methods
withMethods((store, todoService = inject(TodoService)) => ({
// Sync methods
addTodo(text: string) {
const newTodo: Todo = {
id: Date.now(),
text,
done: false,
createdAt: new Date()
};
store.update(state => ({
todos: [...state.todos, newTodo]
}));
},
toggleTodo(id: number) {
store.update(state => ({
todos: state.todos.map(t =>
t.id === id ? { ...t, done: !t.done } : t
)
}));
},
deleteTodo(id: number) {
store.update(state => ({
todos: state.todos.filter(t => t.id !== id)
}));
},
setFilter(filter: TodoFilter) {
store.update({ filter });
},
// Async method with rxMethod
loadTodos: rxMethod<void>(
pipe(
tap(() => store.update({ loading: true, error: null })),
switchMap(() =>
todoService.getTodos().pipe(
tapResponse({
next: (todos) => store.update({ todos, loading: false }),
error: (error: Error) => store.update({
error: error.message,
loading: false
})
})
)
)
)
)
}))
);
// 2. Component (20 lines! 🎉)
@Component({
selector: 'app-todo',
template: `
<div>
@if (todos.loading()) {
<p>Loading...</p>
} @else {
<input #input (keyup.enter)="addTodo(input.value); input.value = ''">
<div class="filters">
<button (click)="todos.setFilter('all')">All</button>
<button (click)="todos.setFilter('active')">
Active ({{ todos.activeCount() }})
</button>
<button (click)="todos.setFilter('completed')">
Completed ({{ todos.completedCount() }})
</button>
</div>
@for (todo of todos.filteredTodos(); track todo.id) {
<div class="todo">
<input
type="checkbox"
[checked]="todo.done"
(change)="todos.toggleTodo(todo.id)"
>
<span [class.done]="todo.done">{{ todo.text }}</span>
<button (click)="todos.deleteTodo(todo.id)">×</button>
</div>
}
}
</div>
`
})
export class TodoComponent implements OnInit {
todos = inject(TodoStore);
ngOnInit() {
this.todos.loadTodos();
}
addTodo(text: string) {
if (text.trim()) {
this.todos.addTodo(text.trim());
}
}
}
// Total: 2 files, ~120 lines
// No observables in template
// No async pipes
// Auto-updates everywhere
// Pure reactive magic ✨Comparison:
NgRx Store: 5 files, 400+ lines, observable hell
Component Store: 2 files, 180 lines, RxJS heavy
Signal Store: 2 files, 120 lines, PURE BLISS
Winner: Signal Store (by knockout)V ďalšej časti sa pozrieme na Pros & Cons, migračné stratégie, advanced patterns a real-world lessons.