Привет, Хабр!
Разрешите поделиться своим велосипедом. Речь пойдет о минималистичном менеджере состояний React, интерфейс которого состоит из одной функции — createShared()
.
Дисклеймер
Автор не имеет существенного опыта использования популярных менеджеров состояний. Одна из целей публикации — собрать фидбек и мнения разработчиков плотно знакомых с устоявшимися решениями. Расскажите, какие фичи используемого вами стейт-менеджера вы особенно цените.
Также автор понимает, что это плохая практика — изобретать свое, должным образом не ознакомившись с тем что есть. Надеюсь на вашу снисходительность =)
Зачем
В течение пары лет мы работаем над некоторым React приложением: ~160 файлов, в среднем по 100 строк кода, половина из файлов — React компоненты разной степени сложности. Проект начали писать после появления хуков (hook) в React, так что 99.9% всех компонентов у нас написаны в "функциональном стиле".
Все это время мы не использовали никакой менеджер состояний. Я вообще считал, что они "не нужны". И, действительно, все наши потребности по взаимодействию компонентов находящихся в разных поддеревьях успешно решались с помощью связки useState
+ контекст.
И все было хорошо, пока таких состояний (доступ к которым нужен из разных "уголков вселенной") не стало порядка 10. После чего количество бойлерплейта, необходимого для поддержки каждого из состояний, стало превышать комфортный уровень. В ответ на эту проблему был написан стейт-менеджер о котором и рассказано в этой статье.
Кроме того, мы недавно полностью перешли с референс-реализации react на preact. Как же я восхищен этим проектом! Полный аналог react (еще с дополнительными функциями) менее, чем за 10кб. Это просто искусство. Я старался смотреть на preact как на образец компактности при разработке стейт-менеджера (в основном с preact его и планируется использовать).
Далее привожу перевод основных моментов документации: примеры использования и описание API.
Если вам удобнее почитать на английском, приглашаю сразу посмотреть полную версию или TL;DR версию, после чего перейти к Заключению статьи.
Установка
npm install --save whoosh-react
Примеры
Простой счетчик
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)
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)
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 сам разберется с порядком обновлений
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 сам разберется с порядком обновлений
Пользователю тогда придется явно оборачивать корневой компонент в этот провайдер... Но может это действительно разумный трейд-офф.
faiwer
07.01.2022 17:53+1toLocalStorage ведет себя как чистая функция. Он только записывает текущее значение состояния в localStorage не читая ничего обратно (это делает инициализатор) и не модифицируя состояние. Да, формально, это не чистая функция
О_о. Это что угодно, но не чистая функция. Как формально, так и неформально. У pure function всего 2 требования. И одно из них тут нарушено.
Alex-111 Автор
07.01.2022 18:10И не спорю, не чистая. Я о том, что в конкретном use-case это несущественно, т.к. не вызовет каких-либо неприятностей.
justboris
07.01.2022 19:37Это в вашем варианте использования не существенно. Но раз вы делаете библиотеку в общем доступе, то и конвенциям сообщества соотвествовать надо. Тем более, что от вас многого и не требуется – достаточно поменять терминологию
strannik_k
08.01.2022 10:14Зачем вам редьюсеры? Уверен, что без них код получился бы проще. Я уже писал ранее, что это ненужное усложнение в Redux и useReducer:
https://habr.com/ru/post/546606/
https://github.com/sergeysibara/unusual-hooks#usestatewithupdaters---usereducer-alternative
Alex-111 Автор
08.01.2022 17:55С редьюсерами такая ситуация, что относительно простые состояния (например, представляемые одним фундаментальным объектом JS без вложенностей) на них реализовать проще/понятнее и субъективно красивее. Именно на такие случаи в первую очередь и рассчитана эта библиотека: когда приложению требуется простое состояние с глобальной видимостью.
Сложная же логика с кучей акшенов и состоянием представляемым множеством связанных объектов удобнее реализовывается через класс / объект с методами.
Как и везде, нет универсального решения лучшего во всех случаях.
markelov69
08.01.2022 17:02+1Серьезно? Пожалуйста, используйте Javascript'овые getters/setters когда вы пишите стейт менеджеры, в чем проблема то? Это намного сократит и упростит код, который нужно писать. В таком виде как вы это сделали это никуда не годится. Либо можете вообще не заморачиваться и взять MobX, там уже давным давно все сделано по уму и с максимальным удобством использования.
Alexandroppolus
Похоже на Hookstate
Кейсы с возможными устаревшими пропсами и зомби-чилдами не проверяли?
В МобХ: тонкие оптимизации "из коробки" с абсолютным минимумом лишнего кода (подписка на стейт по факту использования в отличии от безусловной подписки в хуке; компутеды; экшены), синхронное обновление всех производных стейтов, простые мутабельные обновления развесистых объектов.
Alex-111 Автор
Спасибо за комментарий.
На сколько смог разобраться, данные "баги" больше на плечах пользователя, чем на библиотеке. Ну я повторюсь, что в частности с МобХ не достаточно знаком.
В первом случаи пропсы должны быть указаны в списке зависимостей (и, соответственно, акшен зависящий от пропов должен запускаться синхронно через эффект).
Во втором случаи, при использовании не-локального состояния лучше не делать никаких предположений о текущем состоянии, оно может быть любым (в рамках объявленного типа).
Тут соглашусь, такая функция должна присутствовать даже в минимальном наборе. Будем добавлять возможность указания используемых полей объекта в хуке, по типу списка зависимостей.
Alexandroppolus
Это важный поинт, но я говорил о другом. Пример:
store - некий мобиксовый стор, из которого читаем (и подписываемся на) наблюдаемое поле text. И если мы не добрались до этой ветки, а например вышли из-за error, то не читаем и не подписываемся.
Alex-111 Автор
Просто сначала подумал, что
store.text
явно указывается в списке зависимостей до его использования (и пока не указан, не доступен пользователю). Интересная фитча. Попробую поиграть с оборачиваниемstore
в `Proxy` для автоматического детектирования чтения свойств. Еще раз благодарю за пример.