Создание своего стейт-менеджера в 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(...);
}