React ⚛️
June 6

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