Obsah článku / Contents
Príchod mesiáša: NgRx Signal Store - Úvod a porovnanie (Časť 1)

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 everywhere

Developer 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

AspektNgRx Store (Redux)Signal StoreWinner
Lines of code~155 (5 files)~50 (2 files)🏆 Signal Store
BoilerplateMAXIMUMMinimal🏆 Signal Store
Learning curveSteep AFGentle slope🏆 Signal Store
ObservablesEverywhereOptional🏆 Signal Store
async pipesRequiredNot needed🏆 Signal Store
Type safetyGoodExcellent🏆 Signal Store
DevToolsRedux DevToolsTBD (coming)🏆 NgRx Store
Time travelYesNot yet🏆 NgRx Store
Global stateNaturalNatural🤝 Tie
DebuggingExcellentGood🏆 NgRx Store
PerformanceGoodBetter🏆 Signal Store
Bundle sizeLargerSmaller🏆 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

AspektComponent StoreSignal StoreWinner
SyntaxClass-basedFunctional🏆 Signal Store
ComposabilityLimited (single inheritance)Excellent (features)🏆 Signal Store
RxJS requiredYesOptional🏆 Signal Store
Local statePerfectPerfect🤝 Tie
ModularityOKExcellent🏆 Signal Store
Custom featuresHardEasy🏆 Signal Store
Migration pathEasy to Signal StoreN/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

AspektPure SignalsSignal StoreWinner
SimplicityMaximumHigh🏆 Pure Signals
StructureDIYBuilt-in🏆 Signal Store
Team workChaoticOrganized🏆 Signal Store
ScalabilityLimitedExcellent🏆 Signal Store
Side effectsManualrxMethod🏆 Signal Store
Best forTiny appsReal appsDepends

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 heavy

Version 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.

Všetky články