Создание своего стейт-менеджера в React
В этой статье мы создадим простую, но эффективную систему управления состоянием для React
. Мы не будем использовать Context API, потому что с его использованием в проект приходит хаос. Более того, мы хотим написать решение, которое не будет использовать имеющийся функционал React
.
Давайте начнем с реализации хранилища:
export class Store<State extends Record<string, any>> { private subscribers = new Set<Subscriber<State>>(); constructor(private state: State) { } getValue() { return this.state; } setValue(newState: State | ((state: State) => State)) { this.state = typeof newState === 'function' ? newState(this.state) : newState; this.emit(); } updateValue(partialState: Partial<State>) { this.state = { ...this.state, ...partialState } this.emit(); } subscribe(subscriber: Subscriber<State>) { this.subscribers.add(subscriber); return () => this.subscribers.delete(subscriber) } private emit() { this.subscribers.forEach((subscriber) => subscriber(this.state)); } }
Хранилище ожидает объект в качестве начального значения. На практике мы можем сделать возможным использование примитивов, но для простоты оставим как есть. API прост в использовании. Мы предоставляем методы для установки, обновления, получения и подписки на текущее состояние. При обновлении состояния мы передаем новое значение каждому подписчику.
Чтобы сделать его более функциональным, мы можем обернуть его в функцию:
export function createStore<State extends Record<string, any>>(state: State) { return new Store(state); }
Теперь мы можем использовать нашу функцию createStore
, не привязанную к какому-либо фреймворку:
type TodosState = { filter: 'ALL' | 'ACTIVE'; todos: Array<{ id: string, title: string }> } export const todosStore = createStore<TodosState>({ todos: [], filter: 'ALL' }) const dispose = todosStore.subscribe(state => { console.log(state); }) todosStore.setValue(newValue); todosStore.updateValue({ filter: 'ACTIVE' })
Подключим его к React
Теперь мы можем создать привязку React
, используя хук useSyncExternalStore
. Мы хотим иметь возможность выбирать фрагмент состояния в наших компонентах и перерисовывать его только при изменениях. Давайте создадим функцию createSelectorHook
:
export function createSelectorHook<State extends Record<string, any>>( store: Store<State> ) { return function useSelector<R>(selector: (state: State) => R = (state) => state as R): R { let currentState = useRef<R>(selector(store.getValue())); const getSnapshot = () => selector(store.getValue()) return useSyncExternalStore( useCallback(cb => { return store.subscribe((state) => { const nextState = selector(state); if (currentState.current !== nextState) { currentState.current = nextState; cb(); } }); }, []), getSnapshot, getSnapshot, ); } }
Функция createSelectorHook
принимает экземпляр Store
и возвращает хук функции useSelector
. Функция useSelector
принимает необязательный селектор, который по умолчанию возвращает весь стейт. Мы сохраняем текущее значение в useRef
хук.
Первый аргумент для useSyncExternalStore
- это функция подписки, которая предоставляет коллбек, который мы должны вызвать при изменении хранилища. В нашем случае мы подписываемся, сравниваем текущее значение с новым и вызываем ее при каждом изменении.
Второй и третий аргументы - это функции, которые возвращают текущее значение хранилища (третий используется во время серверного рендеринга).
Давайте используем функцию createSelectorHook
с хранилищем todos
:
// todos.store.ts import { randUuid } from '@ngneat/falso'; import { createSelectorHook, createStore } from './store'; type TodosState = { filter: 'ALL' | 'ACTIVE'; todos: Array<{ id: string, title: string }> } export const todosStore = createStore<TodosState>({ todos: [], filter: 'ALL' }) export const useTodosSelector = createSelectorHook(todosStore); export function addTodo(title: string) { todosStore.setValue(state => { return { ...state, todos: [...state.todos, { title, id: randUuid() }] } }) }
// Todos.ts import { randText } from '@ngneat/falso'; import { addTodo } from './todos.store'; function Todos() { const todos = useTodosSelector(state => state.todos); return <> {todos.map(({ title, id }) => <p key={id}>{title}</p>)} <button onClick={() => addTodo(randText())}>Add todo</button> </> }
Использование Reselect
Мы можем пойти дальше и воспользоваться библиотекой reselect
, которая часто используется с Redux
, для создания переиспользуемых, эффективных и композиционных селекторов.
// shop.store.ts import { createSelector } from 'reselect' import { createSelectorHook, createStore } from './store'; interface ShopState { taxPercent: number; items: Array<{ name: string, value: number }> } const selectShopItems = (state: ShopState) => state.items const selectTaxPercent = (state: ShopState) => state.taxPercent const selectSubtotal = createSelector(selectShopItems, items => items.reduce((subtotal, item) => subtotal + item.value, 0) ) const selectTax = createSelector( selectSubtotal, selectTaxPercent, (subtotal, taxPercent) => subtotal * (taxPercent / 100) ) export const selectTotal = createSelector( selectSubtotal, selectTax, (subtotal, tax) => subtotal + tax ) export const shopStore = createStore<ShopState>({ taxPercent: 8, items: [ { name: 'apple', value: 1.2 }, { name: 'orange', value: 0.95 } ] }) export const useShopSelector = createSelectorHook(shopStore);
Компонент, который использует селектор, будет перерисовываться только в том случае, если изменится одна из зависимостей селектора.
function ShopTotal() { const total = useShopSelector(selectTotal); return <p>{total}</p> } function ShopItems() { const items = useShopSelector(selectShopItems); return items.map(...); }