Whoosh — минималистичный менеджер состояний React

Kate

Administrator
Команда форума
Речь пойдет о минималистичном менеджере состояний React, интерфейс которого состоит из одной функции — createShared().


pubcjdq4fszxewd8makwqsuvuqo.png



GitHub репозиторий проекта

Дисклеймер
Автор не имеет существенного опыта использования популярных менеджеров состояний. Одна из целей публикации — собрать фидбек и мнения разработчиков плотно знакомых с устоявшимися решениями. Расскажите, какие фичи используемого вами стейт-менеджера вы особенно цените.
Также автор понимает, что это плохая практика — изобретать свое, должным образом не ознакомившись с тем что есть. Надеюсь на вашу снисходительность =)

Зачем​


В течение пары лет мы работаем над некоторым React приложением: ~160 файлов, в среднем по 100 строк кода, половина из файлов — React компоненты разной степени сложности. Проект начали писать после появления хуков (hook) в React, так что 99.9% всех компонентов у нас написаны в "функциональном стиле".


Все это время мы не использовали никакой менеджер состояний. Я вообще считал, что они "не нужны". И, действительно, все наши потребности по взаимодействию компонентов находящихся в разных поддеревьях успешно решались с помощью связки useState + контекст.


И все было хорошо, пока таких состояний (доступ к которым нужен из разных "уголков вселенной") не стало порядка 10. После чего количество бойлерплейта, необходимого для поддержки каждого из состояний, стало превышать комфортный уровень. В ответ на эту проблему был написан стейт-менеджер о котором и рассказано в этой статье.


Кроме того, мы недавно полностью перешли с референс-реализации react на preact. Как же я восхищен этим проектом! Полный аналог react (еще с дополнительными функциями) менее, чем за 10кб. Это просто искусство. Я старался смотреть на preact как на образец компактности при разработке стейт-менеджера (в основном с preact его и планируется использовать).




Далее привожу перевод основных моментов документации: примеры использования и описание API.


Если вам удобнее почитать на английском, приглашаю сразу посмотреть полную версию или TL;DR версию, после чего перейти к Заключению статьи.


Установка​


npm install --save whoosh-react


Примеры​


Простой счетчик​


Этот пример на codesandbox.io


1。 Создаем Shared State


// AppState.ts
import { createShared } from 'whoosh-react';

export const appCounter = createShared<number>(0);

createShared() принимает начальное значение и возвращает объект представляющий Shared State.


2。 Используем Shared State в React компонентах


// Counter.tsx
import { appCounter } from './AppState.ts';

const CounterValue = () => {
const counter = appCounter.use();
return <p> { counter } </p>;
};

const CounterControls = () => {
const reset = () => appCounter.set(0);
const addOne = () => appCounter.set(previousValue => previousValue + 1);
return (<>
<button onClick={reset} > Reset </button>
<button onClick={addOne} > Add 1 </button>
</>);
};

В примере используются две функции-члена Shared State:


  • use() возвращает текущее значение Shared State.
    Это React хук который запустит ре-рендер компонента при изменении Shared State.
  • set() — обычная JS функция устанавливающая новое значение Shared State. Функция принимает либо новое значение, либо функцию принимающую предыдущее значение состояния и возвращающую новое.

3。 Рендер компонентов. Компоненты могут быть в любом месте React дерева.


const RootComponent = () => (
<>
<A>
<CounterValue/>
</A>
<B>
<CounterControls/>
</B>
</>
);

Счетчик с редьюсером (reducer)​


Этот пример на codesandbox.io


createShared() имеет второй опциональный аргумент — редьюсер.


В контексте менеджеров состояний редьюсером принято называть функцию типа (previousValue: S, input: A) => S — это функция перехода из старого состояния previousValue типа S в новое состояние (тоже типа S) на основе аргумента input типа A. В других решениях input часто называют action.


Значение input будет передано в редьюсер из функции set(input) при ее вызове пользователем. (Когда используется редьюсер, тип аргумента set() меняется с S на A).


// AppState.ts
type CounterOp = { operation: 'add' | 'subtract' | 'set'; arg: number; };

export const appCounter = createShared<number, CounterOp>(
0,
(previousValue, { operation, arg }) => {
switch(operation) {
case 'add': return previousValue + arg;
case 'subtract': return previousValue - arg;
case 'set': return arg;
}
throw new Error(`appCounter Reducer: operation ${operation} is not supported!`)
}
);

// Counter.tsx
const CounterControls = () => {
const reset = () => appCounter.set({operation: 'set', arg: 0});
const addOne = () => appCounter.set({operation: 'add', arg: 1});
return (<>
<button onClick={reset} > Reset </button>
<button onClick={addOne} > Add 1 </button>
</>);
};

В этом примере, если в appCounter.set() передано некорректное значение аргумента, то в функцию позвавшую set() будет брошено исключение редьюсером.


Использование функции в качестве аргумента set() по-прежнему валидно:


const toggleBetween0and1 = () => appCounter.set(
previousValue => ({
operation: (previousValue > 0? 'subtract' : 'add'),
arg: 1
})
);

Библиотека редьюсеров​


Наиболее часто используемые редьюсеры реализованы в библиотеке.


Библиотеку редьюсеров планируется постепенно расширять, сейчас она содержит следующие функции:


  • редьюсер toLocalStorage(), позволяющий сохранять Shared State в localStorage;
  • редьюсеры arrayOp и setOp:
    • arrayOp добавляет к состоянию типа Array<S> операции remove, add, filter и map,
    • setOp добавляет к состоянию типа Set<S> операции remove и add;
  • функция compose() позволяет делать композицию редьюсеров.

Пример использования редьюсера arrayOp из библиотеки​


import { arrayOp, ArrayOpInput } from 'whoosh-react/reducers';

// Array of strings that also can be undefined
type StateType = string[] | undefined;
const stateArray = createShared<StateType, ArrayOpInput< StateType >>(
undefined, arrayOp
);

// Valid calls of `set()`:
stateArray.set([]);
stateArray.set(['abc', '123']);
stateArray.set(prev => ['abc', '123', ...prev]);

stateArray.set({remove: 'abc'});
stateArray.set({add: '123'});
stateArray.set({map: (str, idx) => `${idx}-${str}`});
stateArray.set({filter: str => str.length > 0});

stateArray.set({remove: '123', add: 'abc'});

stateArray.set(undefined);

Пример композиции редьюсеров​


import { toLocalStorage, arrayOp, ArrayOpInput, compose } from 'whoosh-react/reducers';
import { createShared } from 'whoosh-react';

type MusicGenresType = string[];
const musicGenres = createShared<MusicGenresType, ArrayOpInput<MusicGenresType>>(
[], compose(toLocalStorage('userPreferences.genres'), arrayOp)
);

// ...
musicGenres.set({add: 'rock'});

В этом примере musicGenres сохраняется в localStorage (и извлекается из него при запуске)
и одновременно возможна работа через операции реализуемые редьюсером arrayOp.


Интерфейс объекта Shared State​


createShared() возвращает объект SharedState со следующим интерфейсом


// S - Тип состояния
// A - Тип аргумента input редьюсера (если редьюсера нет, то A === S)

interface SharedState<S, A = S> {
use(): S;
get(): S;
set(a: A | ((s: S) => A)): void;
on(cb: (state: S) => void): () => void;
off(cb: (state: S) => void): void;
}

  • use() возвращает текущее значение Shared State.
    Это React хук который запустит ре-рендер компонента при изменении Shared State. Должен следовать правилам использования React хуков. Может быть использован только в функциональных компонентах.
  • get() получить текущее значение Shared State. Функция полезна когда нужно получить текущее значение асинхронно, не вызывая лишних ре-рендеров компонента.
  • set() обновляет значение Shared State. Принимает либо новое значение, либо функцию принимающую предыдущее значение состояния и возвращающую новое.
    Новое значение должно быть типа S если редьюсер не используется, либо типа A, если используется. (Разумеется, ничто не мешает использовать редьюсер в котором S === A).
    Обновление значения вызовет ре-рендер всех примонтированных компонентов использующих хук use() данного Shared State.
  • on() и off() позволяют подписаться и отписаться на/от изменений Shared State. Полезно для выноса логики взаимодействия Shared State из компонентов.
    on() также возвращает функцию, вызвав которую можно отписаться от изменений Shared State.

Все функции SharedState гарантированно стабильны. Их можно не добавлять в списки зависимостей useEffect и других хуков.


Все функции SharedState не нуждаются в привязке (binding, bind()). Это простые функции, а не методы.


Функция createShared()​


// S - Тип состояния
// A - Тип аргумента input редьюсера (если редьюсера нет, то A = S)
// I - Тип аргумента функции-инициализатора (если редьюсер и инициализатор присутствуют)

type Reducer<S, A> = (previousState: S, input: A) => S;
type ReducerAndInit<S, A, I> = [ Reducer<S, A>, (initArg: I) => S ];
type ReducerOrReducerWithInit<S, A> = Reducer<S, A> | ReducerAndInit<S, A, S>;

function createShared<S>(
initValue: S,
reducer?: ReducerOrReducerWithInit<S, S>
): SharedState<S, S>;

function createShared<S, A>(
initValue: S,
reducer: ReducerOrReducerWithInit<S, A>
): SharedState<S, A>;

function createShared<S, A, I>(
initValue: I,
reducer: ReducerAndInit<S, A, I>
): SharedState<S, A>;

createShared() принимает два аргумента: начальное значение initialValue (обязательно) и редьюсер reducer (опционально).


Редьюсер может быть представлен либо функцией, либо кортежем (массивом) из двух элементов: функции-редьюсера и функции-инициализатора. Инициализатор пререводит initialValue типа I в значение начального состояния типа S (допустимо I === S).


Использование с классовыми компонентами​


Whoosh создан с расчетом использования в современных React приложениях, в которых используются только функциональные компоненты. Однако, при необходимости поддержки классовых компонентов можно вручную подписаться на изменение состояния с помощью on() в componentWillMount() и отписаться с помощью off() в componentWillUnmount().


Заключение​


Как вы могли заметить, не малая доля бойлерплейта идет от типизации: при использовании сложных редьюсеров (в которых тип аргумента является сложением типа состояния S с другими типами) дженерики не выводятся автоматически (точнее, выводятся, но неправильно, что еще хуже, блин), так что приходится вручную передавать типы в createShared.


Может я не достаточно хитро продекларировал перегрузки. Эксперты typescript, буду рад замечаниям.


О некоторых спорных решениях, отличных [может быть] от общепринятых​


  • Упаковка логики создания простого состояния и состояния с редьюсером в одну функцию.
    Данное решение призвано подтолкнуть пользователя в пользу идеи постепенного увеличения сложности. Всегда можно начать с обычного состояния и далее добавить функцию-редьюсер при возникшей необходимости (не нарушая работу уже написанного кода).
  • Хук use() как функция-член объекта, а не как отдельная функция.
    Позволяет не делать дополнительный import в каждый файл, использующий Shared State.
    Сейчас:
    import { appCounter } from './AppState.ts';

    const CounterValue = () => {
    const counter = appCounter.use();
    return <p> { counter } </p>;
    };

    Как было бы иначе:
    import { appCounter } from './AppState.ts';
    import { useShared } from 'whoosh';

    const CounterValue = () => {
    const counter = useShared(appCounter);
    return <p> { counter } </p>;
    };

  • Второй аргумент редьюсера назван input, а не action, как это везде принято.
    Решение связано с зависимостью семантики функции set() от используемой перегрузки createShared(). Например, в React при использовании простого состояния useState() функцию модификации принято называть setter, а при использовании состояния с редьюсером useReduce() функцию модификации называют dispatcher. Dispatcher имеет логично названный аргумент action.
    В Whoosh нет разделения на состояния с и без редьюсера, функция модификации для обоих случаев одинакова — set(). Семантика аргумента функции зависит от того, присутствует ли редьюсер и от "смысла" заложенного в него, так что аргумент был назван более обобщенно — input.

На этом все, спасибо за внимание!

 
Сверху