Привет, Хабр!


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



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.



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



Комментарии (13)


  1. Alexandroppolus
    06.01.2022 22:07
    +4

    Похоже на Hookstate

    Кейсы с возможными устаревшими пропсами и зомби-чилдами не проверяли?

    Расскажите, какие фичи используемого вами стейт-менеджера вы особенно цените.

    В МобХ: тонкие оптимизации "из коробки" с абсолютным минимумом лишнего кода (подписка на стейт по факту использования в отличии от безусловной подписки в хуке; компутеды; экшены), синхронное обновление всех производных стейтов, простые мутабельные обновления развесистых объектов.


    1. Alex-111 Автор
      06.01.2022 23:09

      Спасибо за комментарий.

      Кейсы с возможными устаревшими пропсами и зомби-чилдами не проверяли?

      На сколько смог разобраться, данные "баги" больше на плечах пользователя, чем на библиотеке. Ну я повторюсь, что в частности с МобХ не достаточно знаком.
      В первом случаи пропсы должны быть указаны в списке зависимостей (и, соответственно, акшен зависящий от пропов должен запускаться синхронно через эффект).
      Во втором случаи, при использовании не-локального состояния лучше не делать никаких предположений о текущем состоянии, оно может быть любым (в рамках объявленного типа).

      подписка на стейт по факту использования в отличии от безусловной подписки в хуке

      Тут соглашусь, такая функция должна присутствовать даже в минимальном наборе. Будем добавлять возможность указания используемых полей объекта в хуке, по типу списка зависимостей.

       


      1. Alexandroppolus
        07.01.2022 02:07

        возможность указания используемых полей объекта в хуке

        Это важный поинт, но я говорил о другом. Пример:

        const Comp = observer(() => {
          ...
          if (...error...) {
            return <div>error!!1</div> 
          }
          return <div>{store.text}</div>
        });

        store - некий мобиксовый стор, из которого читаем (и подписываемся на) наблюдаемое поле text. И если мы не добрались до этой ветки, а например вышли из-за error, то не читаем и не подписываемся.


        1. Alex-111 Автор
          07.01.2022 08:04

          Просто сначала подумал, что store.text явно указывается в списке зависимостей до его использования (и пока не указан, не доступен пользователю). Интересная фитча. Попробую поиграть с оборачиванием store в `Proxy` для автоматического детектирования чтения свойств. Еще раз благодарю за пример.


  1. justboris
    07.01.2022 01:07
    +4

    Извините пожалуйста, но разрешите докопаться.

    редьюсер toLocalStorage(), позволяющий сохранять Shared State в localStorage;

    Редьюсер по определению своему - это чистая функция. Если у вас там возможны сайд-эффекты, то это уже middleware

    Хук use() как функция-член объекта, а не как отдельная функция.

    В реакте есть определенная конвенция, что хуками являются только функции, начинающиеся с use*. Своим именованием вы ломаете их eslint плагин, devtools и возможно еще всякий другой тулинг.

    https://github.com/AlexIII/whoosh/blob/main/src/whoosh.ts#L58

    А зачем вы фризите входной объект? С одной стороны понятно, чтобы его никто не вздумал мутировать, но с другой стороны, это должно решаться иными средствами, например, типами.

    https://github.com/AlexIII/whoosh/blob/main/src/whoosh.ts#L37

    Асинхронный scheduling внушает опасение. Можно попать в infinite loop, который очень сложно сдетектить, потому что из-за асинхронных обновлений переполнения стека не происходит. Кроме того, зомби-чилды из комментария выше так и возникают.

    По-хорошему, лучше делегировать обновление стейта самому фреймворку. Сделать один Provider c useState, use-хук будет писать и читать из этого провайдера, а react сам разберется с порядком обновлений


    1. Alex-111 Автор
      07.01.2022 08:35

      Извините пожалуйста, но разрешите докопаться.

      Благодарен, что вы решили это сделать.

      Редьюсер по определению своему - это чистая функция.

      toLocalStorage ведет себя как чистая функция. Он только записывает текущее значение состояния в localStorage не читая ничего обратно (это делает инициализатор) и не модифицируя состояние. Да, формально, это не чистая функция и, например, мемоизация невозможна (но и бессмысленна).

      Своим именованием вы ломаете их eslint плагин, devtools

      Упустил из виду, что средства разработки опираются на конвенции именования. Протестирую с eslint и devtools.

      А зачем вы фризите входной объект? С одной стороны понятно, чтобы его никто не вздумал мутировать, но с другой стороны, это должно решаться иными средствами, например, типами.

      Вы все правильно написали, это позволяет не просто не допустить незаконных мутаций, но и грубо сообщить об этом пользователю ошибкой. Предполагается, что после передачи входного объекта он становится частью внутреннего состояния библиотеки и больше не принадлежит пользователю.

      К сожалению, типами это не решить. Да и plain JS пользователи, на сколько мне известно, существуют.

      Асинхронный scheduling внушает опасение. Можно попать в infinite loop, который очень сложно сдетектить

      Асинхронный вызов подписчиков был введен в основном как debouncing. В dev режиме планируется добавить детекцию бесконечного цикла обновлений с выводом предупреждения. (Эвристикой: считать количество вызовов подписчиков в единицу времени. Должно поймать основной процент проблем.)

      По-хорошему, лучше делегировать обновление стейта самому фреймворку. Сделать один Provider c useState, use-хук будет писать и читать из этого провайдера, а react сам разберется с порядком обновлений

      Пользователю тогда придется явно оборачивать корневой компонент в этот провайдер... Но может это действительно разумный трейд-офф.


      1. faiwer
        07.01.2022 17:53
        +1

        toLocalStorage ведет себя как чистая функция. Он только записывает текущее значение состояния в localStorage не читая ничего обратно (это делает инициализатор) и не модифицируя состояние. Да, формально, это не чистая функция

        О_о. Это что угодно, но не чистая функция. Как формально, так и неформально. У pure function всего 2 требования. И одно из них тут нарушено.


        1. Alex-111 Автор
          07.01.2022 18:10

          И не спорю, не чистая. Я о том, что в конкретном use-case это несущественно, т.к. не вызовет каких-либо неприятностей.


          1. justboris
            07.01.2022 19:37

            Это в вашем варианте использования не существенно. Но раз вы делаете библиотеку в общем доступе, то и конвенциям сообщества соотвествовать надо. Тем более, что от вас многого и не требуется – достаточно поменять терминологию


            1. Alex-111 Автор
              07.01.2022 20:04
              +1

              Добавил в документацию соответстующее предупреждение.


  1. strannik_k
    08.01.2022 10:14

    Зачем вам редьюсеры? Уверен, что без них код получился бы проще. Я уже писал ранее, что это ненужное усложнение в Redux и useReducer:

    https://habr.com/ru/post/546606/

    https://github.com/sergeysibara/unusual-hooks#usestatewithupdaters---usereducer-alternative


    1. Alex-111 Автор
      08.01.2022 17:55

      С редьюсерами такая ситуация, что относительно простые состояния (например, представляемые одним фундаментальным объектом JS без вложенностей) на них реализовать проще/понятнее и субъективно красивее. Именно на такие случаи в первую очередь и рассчитана эта библиотека: когда приложению требуется простое состояние с глобальной видимостью.

      Сложная же логика с кучей акшенов и состоянием представляемым множеством связанных объектов удобнее реализовывается через класс / объект с методами.

      Как и везде, нет универсального решения лучшего во всех случаях.


  1. markelov69
    08.01.2022 17:02
    +1

    Серьезно? Пожалуйста, используйте Javascript'овые getters/setters когда вы пишите стейт менеджеры, в чем проблема то? Это намного сократит и упростит код, который нужно писать. В таком виде как вы это сделали это никуда не годится. Либо можете вообще не заморачиваться и взять MobX, там уже давным давно все сделано по уму и с максимальным удобством использования.