Содержание

NgRx годами был стандартом для стейта в Angular, но на Signals появился более лёгкий вариант — Signal Store. Тот же предсказуемый стейт, но без actions, reducers и effects. Сегодня переписываем реальный стейт корзины с NgRx на Signal Store и считаем, что выиграли и где компромиссы.

Зачем менять, если NgRx работает

Классический NgRx — Redux-pattern с большим оверхедом: actions, action creators, reducers, selectors, effects. Для каждой фичи ~5 файлов. Когда стейт сложный и команда привычная — оправдано. Когда стейт средний или ты пишешь приложение в одиночку — оверкилл.

Signal Store (@ngrx/signals) построен на Signals из Angular 20 (подробный разбор Signals — в отдельной статье) — то же самое, но:

NgRx Classic Signal Store
Базовая единица Reducer + actions signalStore()
Чтение Selector через store.select() Property access store.cart()
Изменение dispatch(action) store.addItem(item)
Async-логика Effects + RxJS rxMethod или прямой await
Файлов на фичу 4–5 1
DevTools Из коробки Через withDevtools()
Time-travel debugging Да Нет (можно подключить отдельно)
Кривая обучения Высокая Низкая

Главное: Signal Store не противоречит Signals — он встроен в реактивность Angular напрямую. NgRx Classic держит стейт в RxJS-сторе, и ты конвертируешь Observable → Signal на каждой границе.

Что было: корзина на NgRx Classic

Реальный кусок кода — корзина с добавлением, удалением, расчётом суммы и подгрузкой каталога.

// cart.actions.ts
export const addItem = createAction('[Cart] Add Item', props<{ item: CartItem }>());
export const removeItem = createAction('[Cart] Remove', props<{ id: string }>());
export const loadCatalog = createAction('[Cart] Load Catalog');
export const loadCatalogSuccess = createAction(
  '[Cart] Load Catalog Success', props<{ catalog: Product[] }>()
);

// cart.reducer.ts
export const cartReducer = createReducer(
  initialState,
  on(addItem, (state, { item }) => ({ ...state, items: [...state.items, item] })),
  on(removeItem, (state, { id }) => ({
    ...state, items: state.items.filter(i => i.id !== id)
  })),
  on(loadCatalogSuccess, (state, { catalog }) => ({ ...state, catalog }))
);

// cart.selectors.ts
export const selectItems = (state: AppState) => state.cart.items;
export const selectTotal = createSelector(selectItems, items =>
  items.reduce((s, i) => s + i.price * i.qty, 0)
);

// cart.effects.ts
loadCatalog$ = createEffect(() => this.actions$.pipe(
  ofType(loadCatalog),
  switchMap(() => this.api.getCatalog().pipe(
    map(catalog => loadCatalogSuccess({ catalog }))
  ))
));

// cart.component.ts
items = this.store.selectSignal(selectItems);
total = this.store.selectSignal(selectTotal);
add(item: CartItem) { this.store.dispatch(addItem({ item })); }

Четыре файла на фичу + регистрация в provideStore и provideEffects. Понятно куда смотреть, но писать долго.

Что стало: Signal Store

// cart.store.ts
import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { computed, inject } from '@angular/core';
import { switchMap, tap } from 'rxjs';

interface CartState {
  items: CartItem[];
  catalog: Product[];
  loading: boolean;
}

const initialState: CartState = { items: [], catalog: [], loading: false };

export const CartStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),

  withComputed(({ items }) => ({
    total: computed(() => items().reduce((s, i) => s + i.price * i.qty, 0)),
    count: computed(() => items().reduce((s, i) => s + i.qty, 0)),
  })),

  withMethods((store, api = inject(CatalogApi)) => ({
    addItem(item: CartItem) {
      patchState(store, ({ items }) => ({ items: [...items, item] }));
    },
    removeItem(id: string) {
      patchState(store, ({ items }) => ({ items: items.filter(i => i.id !== id) }));
    },
    loadCatalog: rxMethod<void>(
      switchMap(() => {
        patchState(store, { loading: true });
        return api.getCatalog().pipe(
          tap(catalog => patchState(store, { catalog, loading: false }))
        );
      })
    ),
  })),
);

Один файл вместо четырёх. И использование:

@Component({
  template: `
    <p>Total: {{ store.total() }}</p>
    <button (click)="store.addItem(item)">Add</button>
  `
})
export class CartComponent {
  protected readonly store = inject(CartStore);
  ngOnInit() { this.store.loadCatalog(); }
}

Никаких selectSignal, никаких dispatch. Прямой access к стейту и методам.

Разбор по полочкам

withState — это не просто инициализация

withState(initialState) экспонирует каждое поле как отдельный signal. То есть для стейта { items: [], catalog: [] } ты получаешь store.items и store.catalog автоматически.

Это позволяет в withComputed и компонентах обращаться к ним точечно — change detection реагирует на изменение конкретного поля, а не всего объекта.

patchState — единственный способ изменить

// ❌ Не сработает — нельзя мутировать
store.items().push(item);

// ✅ patchState — иммутабельно обновляет состояние
patchState(store, { items: [...store.items(), item] });

// ✅ функциональная форма — когда новое значение зависит от старого
patchState(store, ({ items }) => ({ items: [...items, item] }));

Иммутабельность всё ещё нужна — Signal Store не делает магии под капотом, он просто более удобный API над сигналами.

rxMethod — мост к RxJS

Что делать с async-логикой, дебаунсом, switchMap? Использовать rxMethod:

search: rxMethod<string>(
  pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap(query => api.search(query)),
    tap(results => patchState(store, { results }))
  )
)

Метод вызывается как обычный (store.search('foo')), но внутри — RxJS pipeline. Эквивалент NgRx-effect, только без actions и без отдельного класса.

Если RxJS не нужен — используй обычный async метод:

async loadCatalog() {
  patchState(store, { loading: true });
  const catalog = await firstValueFrom(api.getCatalog());
  patchState(store, { catalog, loading: false });
}

withHooks — onInit / onDestroy

withHooks({
  onInit(store) {
    store.loadCatalog();  // загрузить каталог при первой инъекции
  },
  onDestroy(store) {
    // cleanup, если нужно
  }
})

Удобно для ленивой загрузки данных при первом обращении к стору.

DevTools и debugging

Time-travel дебаггер из NgRx Classic не переехал из коробки, но есть withDevtools() из @ngrx/signals:

import { withDevtools } from '@ngrx/signals';

export const CartStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  withDevtools('cart'),
  // ...
);

В Redux DevTools видно все patchState-вызовы как «события» с диффом стейта. Time-travel работает, но без действий-объектов — события называются именами методов (addItem, removeItem).

💡

withDevtools подключай только в dev-сборке через environment.production, иначе будешь течь в проде на каждый patchState.

Когда оставаться на NgRx Classic

Signal Store — не серебряная пуля. Сценарии, где Classic всё ещё лучше:

  1. Большая команда с устоявшимися практиками — переписывать ради переписывания невыгодно
  2. Сложные effects с координацией — например, concatLatestFrom, цепочки эффектов друг на друга
  3. Жёсткая аудит-история действий — actions-pattern даёт человекочитаемый лог
  4. Plugin-экосистема — entity-adapter, router-store, server-state с гидрацией
  5. Микро-фронты с разделённым стейтом — Classic лучше работает с lazy modules

Для всего остального — Signal Store экономит время и код без ощутимых потерь.

Гибридный сценарий

В крупных проектах не нужно мигрировать всё разом. NgRx Classic и Signal Store сосуществуют — Classic держит общий стейт (auth, router), а новые фичи пишутся на Signal Store. Через год оборачиваешься, и Classic остался только в одном-двух местах, где он реально нужен.

// auth — на NgRx Classic, потому что есть устоявшиеся effects
@Component({})
export class HeaderComponent {
  user = this.store.selectSignal(selectUser);
  cart = inject(CartStore);  // новая фича на Signal Store
}

Итого

  • Signal Store — современная замена NgRx Classic для большинства проектов
  • Один файл вместо 4–5: withState / withComputed / withMethods + опционально rxMethod / withHooks
  • patchState — единственный способ изменить, иммутабельность всё ещё на тебе
  • DevTools работают через withDevtools(), но без классической time-travel-семантики
  • Не мигрируй существующий NgRx ради миграции — гибридный подход быстрее окупается
  • Когда стейт сложный и команда большая — Classic всё ещё может быть оправдан

Рутину переписывания (actions → patchState, selectors → withComputed) можно отдать Claude Code.

Дальше — пробуй: возьми один store с 1–2 фичами, перепиши, замерь LoC. У меня выходит сокращение в 3 раза при том же поведении и тестируемости.

Поделиться: