Создание Signal Store

Store, созданный при помощи библиотеки Signal Store, представляет в конечном итоге собой объект, у которого все свойства являются сигналами. Он может быть на любом уровне приложения — глобальный (на все приложение, т.е. провайдится в root), для отдельного компонента или группы компонентов (роута).

Хорошей практикой является создание «кусочков» store в отдельных файлах — slice (some-name.slice.ts). Которые лежат в папке store рядом с основным файлом (some-name.store.ts).

Простой пример

Представим, что создаем приложение с опросом (quiz), где поочередно будут меняться вопросы с вариантами ответов.

Пример страницы приложения Quiz

Оно максимально простое и одностраничное. Поэтому создадим в корне папку store.

Структура папки Store в NgRx Store библиотеке
Структура папки Store в NgRx Store библиотеке (quize — опечатка)

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, но ближе к редакс-патерну.

Создадим отдельный файл рядом в папке

Структура папки Store в NgRx Store библиотеке c updater.ts

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),
)

 

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *