Angular
NgRx SignalStore
Сервис состояния фичи разросся: десяток приватных сигналов, computed для производных, методы загрузки, ручная нормализация списка по id. Логика рабочая, но шаблонная - один и тот же каркас повторяется в каждой фиче. NgRx SignalStore собирает этот каркас в декларативную композицию: withState задаёт состояние, withComputed выводит производное, withMethods добавляет операции, withEntities берёт на себя коллекцию по id. Получается тот же сервис на сигналах, но без ручной обвязки и с автоматически выведенными типами.
- NgRx SignalStore - официальная часть экосистемы NgRx, рекомендованный командой способ управлять состоянием на сигналах в современном Angular
- Фича-сторы в крупных приложениях: список с фильтрами, пагинацией и загрузкой укладывается в один SignalStore вместо ручного сервиса
- Миграция с классического Redux-стора: команды переносят локальные фича-сторы на SignalStore ради меньшего объёма кода
- Коллекции сущностей (заказы, пользователи, товары): withEntities даёт нормализованное хранилище по id из коробки
- Локальные сторы на уровне компонента: SignalStore можно провайдить в компоненте, и он живёт ровно столько, сколько компонент
Предварительные знания
- Сервис фичи с сигналами и разделение smart/dumb (предыдущий урок)
- Сигналы: signal, computed, чтение значения вызовом
- Dependency injection: inject и провайдинг сервиса
От Redux-стора NgRx к SignalStore
Классический NgRx появился в 2017 году как порт паттерна Redux на Angular: единый стор, экшены, редьюсеры, селекторы, эффекты. Мощно для крупных приложений, но многословно - простая операция требовала экшена, редьюсера и селектора. С приходом сигналов в Angular 16 команда NgRx переосмыслила подход. SignalStore (стабильный в 2023-2024) строится не на потоке экшенов, а на композиции функций-фич над сигналами. Состояние стало читаемым напрямую как сигналы, а boilerplate экшенов и редьюсеров исчез для большинства случаев.
Анатомия SignalStore: withState и withComputed
SignalStore создаётся функцией signalStore, в которую передаются функции-фичи. Базовая фича - withState: она принимает начальное состояние и превращает каждое его поле в сигнал, выводя типы автоматически. withComputed добавляет производные сигналы, которые читают другие сигналы стора. Результат signalStore - обычный инжектируемый класс, который провайдят как сервис.
После withState поля tasks и filter доступны на сторе как сигналы: store.tasks(), store.filter(). withComputed добавил visible и remaining - они пересчитываются автоматически. Типы выведены из interface TasksState, ничего не приходится приводить вручную. Компонент инжектит стор как любой сервис и читает сигналы прямо в шаблоне.
Никаких селекторов из классического NgRx тут нет. Производное состояние - это computed, читаемое как обычный сигнал. Стор для компонента выглядит как объект с сигналами, а внутренняя композиция функций-фич остаётся деталью определения стора.
Что делает функция-фича withState в SignalStore?
withMethods и patchState: изменение состояния
Состояние SignalStore меняется только через функцию patchState - она принимает стор и частичное обновление и заменяет указанные поля иммутабельно. Операции собираются в withMethods: туда передаётся стор, и возвращается объект методов. Внутри методов можно инжектить другие сервисы (например, HttpClient) через inject, потому что withMethods выполняется в контексте внедрения.
patchState заменяет только переданные поля, остальное состояние остаётся прежним. Метод toggle строит новый массив через map - состояние иммутабельно, мутации на месте нет. Метод load инжектит TaskApi прямо в сигнатуре withMethods как параметр по умолчанию: это идиома SignalStore, чтобы получить зависимость в контексте внедрения без отдельного поля.
Состояние нельзя менять, присваивая что-то сигналам напрямую - они readonly снаружи. Единственный путь - patchState. Это сохраняет иммутабельность и держит все изменения состояния в одном предсказуемом канале, что упрощает отладку и интеграцию с DevTools.
Тип частичного обновления в patchState проверяется по форме состояния. Передать поле, которого нет в TasksState, или неверный тип значения не выйдет - компилятор остановит. Поэтому приведения as и any здесь не нужны и противопоказаны: типобезопасность встроена.
Как корректно изменить состояние в SignalStore и почему именно так?
withEntities: коллекции сущностей по id
Коллекция объектов с id - частый случай: список заказов, пользователей, товаров. Хранить её как простой массив неудобно: поиск и обновление по id требуют перебора. Фича withEntities из @ngrx/signals/entities даёт нормализованное хранилище: внутри сущности лежат в словаре по id, а наружу отдаются сигналы entities (массив) и entityMap. Обновляют коллекцию готовые операции вроде setAllEntities, addEntity, updateEntity, removeEntity.
withEntities выводит сигналы entities, entityMap и ids. Метод load складывает весь список через setAllEntities, а toggle обновляет одну запись через updateEntity по id - без ручного перебора массива. Под капотом коллекция хранится как словарь, поэтому доступ и обновление по id дёшевы, а наружу всё равно доступен массив entities для рендера.
| Операция | Что делает |
|---|---|
| setAllEntities(items) | Заменяет всю коллекцию |
| addEntity(item) | Добавляет одну сущность |
| updateEntity({ id, changes }) | Обновляет поля сущности по id |
| removeEntity(id) | Удаляет сущность по id |
Чтобы хранить несколько коллекций в одном сторе или использовать ключ, отличный от id, withEntities принимает именованную конфигурацию (collection и selectId). Это позволяет, например, держать в одном сторе и заказы, и их позиции, не путая сигналы.
Какую задачу решает фича withEntities по сравнению с хранением коллекции как простого массива в withState?
Store против сервиса и против Redux-стора
SignalStore не отменяет ни обычный сервис с сигналами, ни классический Redux-стор NgRx - у каждого своя ниша. Сервис с сигналами хорош для простого состояния одной фичи без шаблонной обвязки. SignalStore выигрывает, когда нужна стандартизированная композиция, коллекции сущностей или единый формат состояния по всей команде. Классический Redux-стор остаётся оправданным для крупных приложений, где ценят строгий поток экшенов, машину состояний и развитую интеграцию эффектов.
- Сервис с сигналами — Минимум кода для простого состояния одной фичи. Полная свобода формы. Подходит, пока нет коллекций сущностей и потребности в едином каркасе.
- SignalStore — Декларативная композиция withState/withComputed/withMethods/withEntities. Авто-типы, нормализованные коллекции, единый формат. Без экшенов и редьюсеров.
- Классический Redux-стор — Экшены, редьюсеры, селекторы, эффекты. Строгий поток и аудируемость. Многословен, оправдан в крупных приложениях со сложными побочными эффектами.
Практический критерий выбора: начинать стоит с сервиса на сигналах. Как только в нескольких фичах повторяется один и тот же каркас состояния, появляются коллекции по id или команде нужен единый стандарт - это сигнал перейти на SignalStore. К полному Redux-стору обращаются, когда поток изменений настолько сложен, что строгая дисциплина экшенов и редьюсеров окупает свою многословность.
SignalStore можно провайдить не только в root, но и на уровне компонента через providers. Тогда стор живёт ровно столько, сколько компонент, и служит локальным стором фичи - удобная середина между чисто локальными сигналами и глобальным состоянием.
Когда переход с обычного сервиса на сигналах к SignalStore оправдан сильнее всего?
Связь с другими темами
Урок продолжает линию управления состоянием. Связи:
- Архитектура состояния — SignalStore - это формализованный сервис фичи с сигналами из предыдущего урока
- Строгая типизация — Стор выводит типы из начального состояния, и строгий режим делает доступ к ним безопасным
- Тестирование — withMethods даёт обычные функции над сигналами, удобные для unit-тестов в Vitest
Итог
- SignalStore из @ngrx/signals строит состояние на сигналах через композицию функций-фич
- withState задаёт исходное состояние и автоматически выводит сигналы и их типы
- withComputed добавляет производные сигналы, withMethods - операции над состоянием через patchState
- withEntities даёт нормализованную коллекцию по id с готовыми сигналами entities и operations
- В отличие от классического Redux-стора NgRx, нет экшенов, редьюсеров и селекторов - состояние читается напрямую как сигналы
- Store выигрывает у обычного сервиса, когда нужна стандартизированная композиция, коллекции сущностей или общий формат состояния в команде; для простого случая достаточно сервиса с сигналами
Связанные уроки
- ng-38-state-architecture — SignalStore - это эволюция сервиса с сигналами из урока об архитектуре состояния
- ng-41-typescript-angular — SignalStore выводит типы состояния автоматически, и строгая типизация раскрывает его сильнее
- ng-40-testing — Методы стора - обычные функции над сигналами, их легко проверять в Vitest