Создание Signal Store
Store, созданный при помощи библиотеки Signal Store, представляет в конечном итоге собой объект, у которого все свойства являются сигналами. Он может быть на любом уровне приложения — глобальный (на все приложение, т.е. провайдится в root), для отдельного компонента или группы компонентов (роута).
Хорошей практикой является создание «кусочков» store в отдельных файлах — slice (some-name.slice.ts). Которые лежат в папке store рядом с основным файлом (some-name.store.ts).
Простой пример
Представим, что создаем приложение с опросом (quiz), где поочередно будут меняться вопросы с вариантами ответов.
Оно максимально простое и одностраничное. Поэтому создадим в корне папку store.

quiz.slice.ts
import {Question} from '../models/question.model'; import {QUESTIONS} from '../data/questions'; export interface QuizSlice { readonly questions: Question[]; readonly answers: number[]; } export const initialQuizSlice: QuizSlice = { questions: QUESTIONS, answers: [], };
Таких слайсов в одном store может быть несколько.
Кажется, что было бы полезным и поле вроде «currentQuestionIndex«, но на самом деле мы можем его получать через калькуляцию, зная количество элементов в массиве answers, где сохраняются индексы ответов уже в самом store файле.
Извне (там где используем Store) мы не видим разницу между свойствами, созданными через withState и withComputed. Там возвращаются просто сигналы. Но нам в компонентах и не нужно знать, как было создано свойство.
quiz.store.ts
withComputed
withComputed — фича, которая использует уже присутствующие значения в стейте для вычисления новых. Она возвращает объект, где ключи — новые свойства с методами computed в качестве значений.
Не оптимальный пример (в плане calculation фичи)
import {signalStore, withComputed, withState} from '@ngrx/signals'; import {initialQuizSlice} from './quiz.slice'; import {computed} from '@angular/core'; export const QuizStore = signalStore( {providedIn: 'root'}, withState(initialQuizSlice), withComputed((store) => ({ currentQuestionIndex: computed(() => store.answers.length), isDone: computed(() => store.answers.length === store.questions.length), })), withComputed((store) => ({ currentQuestion: computed(() => store.questions()[store.currentQuestionIndex()]), })) )
Как видно в примере, мы используем withComputed фичу, чтобы создать дополнительные свойства на основе имеющихся. При каждом следующем использовании «фичи» withComputed мы имеем доступ к стейту, созданный при помощи предыдущей фичи (withComputed или withState). Именно поэтому нам пришлось еще раз вызывать withComputed для создания currentQuestion, так как в предыдущем withComputed у нас еще нет доступа к currentQuestionIndex. Но есть способ избежать этого.
Оптимальный пример (в плане calculation фичи)
Мы можем просто вынести computed в отдельные переменные и возвращать объект с ними внутри функции withComputed.
export const QuizStore = signalStore( {providedIn: 'root'}, withState(initialQuizSlice), withComputed((store) => { const currentQuestionIndex = computed(() => store.answers.length); const isDone = computed(() => store.answers.length === store.questions.length) const currentQuestion = computed(() => store.questions()[currentQuestionIndex()]); return { currentQuestionIndex, isDone, currentQuestion } }), )
Этот подход более аккуратный.
withMethods и patchState
В отличие от withComputed, эта фича обновляет данные стейта при помощи метода.
export const QuizStore = signalStore( {providedIn: 'root', protectedState: true}, //default is true withState(initialQuizSlice), withComputed((store) => { const currentQuestionIndex = computed(() => store.answers.length); const isDone = computed(() => store.answers.length === store.questions.length) const currentQuestion = computed(() => store.questions()[currentQuestionIndex()]); return { currentQuestionIndex, isDone, currentQuestion } }), withMethods((store) => ({ addAnswer: (answerIndex: number) => { patchState(store, state => ({ answers: [...state.answers, answerIndex] })) } })) )
Это выглядит понятно, но это не лучшая практика. Правильнее будет использовать вместо метода здесь (второго параметра patchState) updater — вынесенную функцию, которая возвращает PartialStateUpdater (обновленный кусочек стейта).
PartialStateUpdater
PartialStateUpdater — это метод, который принимает стейт и возвращает его часть, которую нужно обновить.
По сути, это позволяет выполнить аналогичный метод из предыдущего примера addAnswer, но ближе к редакс-патерну.
Создадим отдельный файл рядом в папке
quiz.updaters.ts
И по сути переносим тот же метод, но вызываем его внутри функции, которую экспортируем.
import {PartialStateUpdater} from '@ngrx/signals'; import {QuizSlice} from './quiz.slice'; export function addAnswer(index: number): PartialStateUpdater<QuizSlice> { return state => ({ answers: [...state.answers, index] }) } export function reset(): PartialStateUpdater<QuizSlice> { return state => ({ answers: [] }) }
И теперь просто импортируем вместо полного метода обратно в стейт
withMethods((store) => ({ addAnswer: (answerIndex: number) => { patchState(store, addAnswer(answerIndex)), reset: () => patchState(store, reset()), } }))
Суть осталась та же, но так удобнее читать код, и можно считать это название addAnswer и reset упрощенным аналогами экшенов. А сами вынесенные методы addAnswer и reset — редьюсерами.
withHooks
Этот метод / фича позволяет выполнять действия со стейтом при инициализации (onInit), обновлении (onInit + effect) или уничтожении (onDestroy). В данном примере onDestroy не имеет смысла, так как мы инжектим наш стор в root, а значит он никогда не уничтожится. Но если бы инжект был на уровне компонента или роута, то этот метод отрабатывал.
Для примера представим, что нужно сохранять текущий стейт при каждом изменении в localStorage и вытягивать его оттуда при инициализации.
... withHooks((store) => ({ onInit: () => { const stateJson = localStorage.getItem('quiz'); if (stateJson) { patchState(store, JSON.parse(stateJson)); } effect(() => { const state = getState(store); const stateJson = JSON.stringify(state); localStorage.setItem('quiz', stateJson); }); } })) ...
Effect здесь создается внутри injection context, т.е. тут не будет ошибки, как при попытке инициализировать effect в методе ngOnInit компонента. И будет отрабатывать каждый раз, когда какое-то из полей обновляется.
Сам же onInit отработает только один раз при создании стейта, и как раз пропатчит его, если он ранее был сохранен в localStorage.
Использование в компоненте
Метод signalStore возвращает класс (не объект (инстанс класса), а сам класс), что немного неожиданным может казаться. Но нам для работы нужен объект. Поэтому нам нужно как-то запровайдить его в приложение (как мы делаем с сервисами). Но так как это не класс, в отличие от сервиса, а функция, то декоратор @Injectable({providedIn: 'root'})
не сработает. И вообще декораторы — это подход ООП, от которого Ангуляр отходит.
Вариант 1 (неоптимальный)
Провайдить можно в массив провайдеров на любом уровне (как делаем с сервисами). На наивысшем это будет в app.config.ts. Но можно хоть в компонент.
Вот так можно было бы запровайдить глобально:
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; import {QuizStore} from './store/quize.store'; export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), QuizStore ] };
Вариант 2 (правильный)
Можно просто параметром передать {providedIn: ‘root’} в саму функцию signalStore:
import {signalStore, withComputed, withState} from '@ngrx/signals'; import {initialQuizSlice} from './quiz.slice'; export const QuizStore = signalStore( {providedIn: 'root'}, withState(initialQuizSlice), )