React-lens — эффективное управление состоянием в приложениях в ReactJs
- суббота, 27 января 2024 г. в 00:00:12
Во многих разрабатываемых программах мы сталкиваемся с необходимостью организации работы с данными. Такие задачи могут быть самыми разными: хранение, актуализация, масштабирование и т. п. А ещё приходится реализовывать взаимодействие различных библиотек. Рассмотрим один из способов решения этих проблем при помощи React Lens.
Как бы мы не хотели, всегда будут ограничения в вычислительных мощностях или человеческих ресурсах. Большую роль оказывает необходимость развивать и расширять свой продукт. Потому баланс задач по организации состояния может быть также разным: где-то нужно сделать упор на производительность, где-то на масштабируемость и т. д.
Сейчас сообщество разработчиков предлагает множество решений: MST, Zustand, hookstate, jotai, react-lens и другие... Однако часто можно столкнуться с тем, что на многих проектах эти решения игнорируются в силу того, что о них мало что известно. Если в предыдущем проекте некая технология показала себя не так хорошо (сильная зацеплённость, боллерплейт), то и в следующем, возможно, будет также. Простым «магическим мышлением» эту ситуацию не решить.
В итоге может возникнуть забавная ситуация, как в выражении - «Мыши плакали, кололись, но продолжали есть кактус». Потому важно иметь представление больше, чем об одной технологии, и в данной статье мы рассмотрим ещё одну такую — ReactLens.
ReactLens — это модульный менеджер состояния, направленных на расширяемость и прозрачность кода.
Первая задача решается предоставлением сразу нескольких подходов расширения: классический, как в ООП и функциональный, которые можно применять одновременно.
Задача прозрачности решается отступлением от декларативного подхода в пользу императивного. Это работает потому, что декларативный подход, хоть и прост на начальных этапах разработки, но он неминуемо создаёт новый (декларативный) язык программирования, который нужно поддерживать и передавать другим разработчикам. И к средине жизненного цикла проекта этот язык может разрастаться и представлять большую проблему для разработки. Императивный подход сложнее изначально, но его сложность не растёт так быстро, как у его оппонента, и по прошествии времени оказывается проще, т. к. зачастую основывается на семантике основного языка.
Ещё одна особенность ReactLens — модульность. Все сущности состояния разбиты на отдельные модели, которые объединяются в один или несколько графов. При этом, каждый узел графа ведёт себя, как корневой. Это означает, что тип узла может быть передан в компонент, как тип принимаемого аргумента без повышения зацеплённости. Другими словами компонент остается независимым от глобального состояния, как и его примитив, что позволяет создавать общие компонентные модели и распространять их между проектами.
Ещё стоит отметить, что ReactLens не хранит данные внутри себя, а реализует интерфейс для работы с ними, т. е. можно создавать модели для работы с локальным хранилищем, кукками или удалёнными данными.
И так, давайте переходить к практике… Для начала рассмотрим пример классического счётчика
const Counter: React.FC = () => {
const [value, setValue] = useState(0);
return <button onClick={() => setValue(value + 1)}>{value}</button>;
}
Как вы считаете, сколько нужно ещё дописать кода, чтобы подключить его к глобальному состоянию? Давайте посмотрим!
const store = createStore(0); // ← одну строчку
const Counter: React.FC = () => {
const [value, setValue] = useLens(store);
return <button onClick={() => setValue(value + 1)}>{value}</button>;
}
Вот так просто... Мы добавили одну строку, инициализирующую состояние и заменили классический хук useState()
на useLens()
. Больше ничего делать не нужно. Всё будет работать, как по волшебству.
Впечатляюще? Если так, то у меня плохие новости. Простые компоненты, как этот счётчик, не должны вызывать стихийного роста сложности кода — «Это норма!». Этот пример просто должен быть таким априори. Если кода больше - возможно что-то не так...
Давайте разберёмся как это работает...
В конце статьи мы ещё раз вернёмся к нашему счётчику и сделаем его ещё красивее, а сейчас немножко теории...
Работа с ReactLens начитается с создания корневой модели и выбором её области использования. Глобальные модели создаются методом createStore()
.
export const store = createStore({ message: 'Hello World!' });
Разработчики рекомендуют создавать несколько маленьких моделей, вместо одной большой.
Локальные модели можно создавать внутри компонентов с помощью хукa useLocalStore()
.
const initStore = { message: 'Hello World!' };
const Component: React.FC = () => {
const store = useLocalStore(initStore);
return <></>;
}
Есть ещё несколько способов организации локального состояния: useDerivedStore()
и useLocalStaticStore()
. См. wiki
ReactLens создаст базовую модель, которая уже будет содержать несколько методов манипуляции с данными. Но мы можем её расширить двумя способами: классическим и функциональным.
Классический способ заключается в создании наследника от базового класса Store
и передаче его в конструктор узла.
class Car extends Store {
move() {
console.log('Move!')
}
}
const car = createStore({}, Car);
car.move(); // Move!
Такой же способ работает и для локального состояния:
const store = useLocalStore(initStore, Car);
На данном этапе можно уже определить тип аргумента, который будет передан в компонент:
interface CarProps {
value: Car;
}
const Component: React.FC<CarProps> = ({ value }) => {
value.move();
...
}
В случаях, когда нам достаточно и базовой модели, можно передать тип через через обобщение.
interface MyType {}
interface CarProps {
value: Store<MyType>;
}
Функциональный способ расширяет сам узел цепочкой методов extends()
. Каждый метод принимает функцию, возвращающую прототип или объект.
const car = createStore({})
.extends(current => {
move: () => console.log('move')
}).
extends(current => { color: 'red' });
store.move(); // move
console.log(store.color ) // red
Такой способ позволяет легко создавать вложенные состояния.
const nested = createStore('Hello!');
const base = createStore({}).extends({ nested });
console.log(base.nested); // Hello!
Также, есть универсальный способ получить вложенное состояние от любого узла, используя метод go()
.
const base = createStore({ deep: { nested: 'Hello!' } });
const nested = base.go('deep').go('nested');
console.log(nested); // Hello!
Метод go()
позволяет получить состояние с определённым прототипом, если передать его во втором аргументе. Это похоже на создание корневого узла.
const nested = base.go('nested', Car);
nested.move();
Ещё go()
кеширует модель и реализует шаблон «одиночка».
Каждый узел ReactLens определяет только интерфейс работы с данными. Это означает, что просто приравнять одно из вложенных состояний к определённому значению не получится. Чтобы получить доступ к данным нужно использовать методы get()
и set()
в каждом из узлов.
const store = createStore('Foo');
console.log(store.get()); // Foo
store.set('Moo');
console.log(store.get()); // Moo
В некоторых случаях, когда данные узла являются примитивом, можно get()
упустить.
const store = createStore('String');
console.log(store + ' is simple'); // String is simple
Стоить отметить, что работать можно с объектами любой вложенности и массивами.
Метод set()
может принимать функцию, которая передаёт предыдущее состояние и должна вернуть новое.
const store = createStore(0);
const add = prev = > prev + 1;
store.set(add); // 1
store.set(add); // 2
На самом деле, ReactLens уже реализует «под капотом» работу с данными. Вам нужно только пользоваться методами, предоставленными хуком
useLens()
.
С созданием состояния разобрались, теперь нужно связать его с компонентом таким образом, чтобы он смог обновиться, если что-то в данных измениться. ReactLens реализует событийную модель по шаблону «подписчик-издатель».
Связать компонент можно при помощи хука useLens()
, который работает как привычный useState()
.
const store = createStore(0);
const Counter: React.FC = () => {
const [value, setValue] = useLens(store);
return <button onClick={() => setValue(value + 1)}>{value}</button>;
}
Связывание можно осуществить и на уровне локального состояния, передавая узел в компонент, как параметр, как мы уже делали раньше.
interface Props {
value: Store<number>;
}
const Counter: React.FC<Props> = ({ value }) => {
const [value, setValue] = useLens(value);
return <button onClick={() => setValue(value + 1)}>{value}</button>;
}
ReactLens не запускает каскадного обновления всех компонентов, а изменяет только связанный компонент, что хорошо влияет на производительность. В следующем примере, обновляться будет только тот счётчик, который используется.
const store = createStore({ one: 0, two: 0 });
const Form: React.FC = () => (
<>
<Counter value={store.go('one')} />
<Counter value={store.go('two')} />
</>
);
Хук useLens()
позволяет фильтровать изменения по большому числу параметров. О них подробно написано в документации. Например, нам нужно, чтобы счётчик обновлялся только на чётные значения.
/* Триггер */
const isEven = (e, node) => node.get() % 2 === 0;
/* Хук будет выглядеть так */
const [value, setValue] = useLens(value, isEven);
На самом деле, если триггер во втором аргументе не указан, useLens()
использует встроенный триггер Trigger.object
, который фильтрует изменения, связанные только с текущим узлом. Потому, если нужно сохранить его функциональность, то следует его тоже добавить.
const [value, setValue] = useLens(value, Trigger.object, isEven);
Иногда нам не обязательно обновлять компонент, а нужно только отловить сам факт изменений. В зависимости от ситуации, ReactLens позволяет сделать это при помощи: расширения модели, метода subscribe()
или хука useSubscribe()
.
Если нужно определить реакцию глобально, то при создании модели можно использовать метод расширения on()
и передать туда нужную реакцию. Также, можно использовать триггер, по принципу useLens()
.
const store = createStore(0)
.on((e, node) => console.log(node)); // 1 2 3 4
.on(isEven, (e, node) => console.log(node)); // 2 4 6 8
Для отслеживания изменений внутри компонента, можно использовать хук useSubscribe()
. Он не приведёт к обновлении компонента.
const callback = (e, node) => console.log(node);
const Component: React.FC = () => {
useSubscribe(store, callback);
...
}
В остальных случаях можно использовать стандартный метод subscribe()
позволяющий организовать подписку на изменения на уровне модели. Его можно использовать для разработки собственных хуков на базе ReactLens.
interface Props<T = unknown> {
value: Store<T>;
}
const Component: React.FC<Props> = ({ value }) => {
useEffect(() => {
const unsubscriber = store.subscribe(value, (e, node) => console.log(node));
return unsubscriber;
}, [value])
...
}
ReactLens имеет широкий спектр поставляемых утилит из коробки. Вот некоторые из них:
Иногда нужно определить глобальные зоны видимости для состояния. Для этого можно использовать хук useLensContext()
, который позволяет использовать узел из передаваемого контекста. Это полезно для тестирования или организации мультиролевой модели. Например, если нужно чтобы форма открывалась от разных пользователей из одного сеанса. (Аналог функции «открыть от имени...»)
ReactLens работает асинхронно. Нет необходимости в использовании специального промежуточного ПО для организации асинхронного взаимодействия. Также есть утилиты Callback.async()
и Callback.pipe()
для организации синхронизированной обработки данных.
Хук useLensDebounce()
позволит быстро организовать работу ленивых полей ввода. Например создадим поле с отложенным вызовом событий на его изменение.
interface Props {
value: Strote<string | number>;
}
const DebounceInput: React.FC<Props> = ({ value }) => {
const [value, setValue] = useLensDebounce(value);
return <input, value={value}, onChange={e => setValue(e.target.value)}>;
}
Мы можем назначать каждому узлу адаптер, который будет работать, как двусторонний преобразователь данных. Сделать это можно несколькими способами: функцией transform()
или более низкоуровневым методом — chain()
. Например, мы хотим, чтобы дата выводилась в формате ISO, но в состоянии хранилась, как число.
const date = createStore(Date.now());
/* Создаём реплику узла, который будет адаптером для date */
const iso = date.transform(
value => new Date(value).toISO(),
iso => new Date(iso).getTime()
);
iso.set(/* ISO string */);
ico.get() // ISO string
date.set(/* number */);
date.get() // number;
Так можно разносить одни и те же данные по полям разного типа или проводить синхронную валидацию.
Мы рассмотрели базовые возможности ReactLens и показали, что при своей функциональности он не требует большой описательной работы. ReactLens старается быть простым и походим на ReactJs, чтобы не вызывать дискомфорта от перехода на него.
И так, ReactLens может:
Работать с глобальным и локальным состояниями
Работать с контекстом или без него
Не требует оборачивания компонентов во врапперы
Позволяет разрабатывать универсальные примитивы
Может расширятся и реализовывать бизнес-логику в модели
Из коробки умеет работать с асинхронным кодом
Умеет обрабатывать события и управлять реакцией на них
Умеет трансформировать данные на лету и создавать адаптеры
Содержит набор утилит, позволяющих расширять большинство базовых функций
Есть и минусы, его популярность не так высока, в сравнении с другими менеджерами. Но так ли важна популярность перед постановкой задачи — решать вам?
Давайте теперь немного улучшим наш счётчик, пример которого мы рассмотрели в начале статьи. Давайте сделаем наш счётчик...
Универсальным компонентом
Универсальным компонентом
Что не сделаешь ради науки...
Остановимся на классическом ООП подходе и реализуем метод increment()
в классе-наследнике от Store
.
class CounterModel extends Store<number> {
increment() {
this.set(this.get() + 1);
}
}
Теперь можем определить интерфейс для параметров компонента.
interface CounterProps {
value: CounterModel;
}
Реализуем сам компонент. В самом компоненте можно использовать функции из нашей модели.
const Counter: React.FC<CounterProps> = ({ value }) => {
const [count] = useLens(value);
return <button onClick={() => value.increment()}>{count}</button>;
}
А само состояние может быть создано с указанием нашей модели CounterModel
, как прототипа.
const counter = createStore(0, CounterModel);
<Counter value={counter}>
Или же её можно взять от любого узла в любом графе состояния...
const store = createStore({ conter: { value: 0 } });
const counter = store.go('counter').go('value', CounterModel);
<Counter value={counter}>
Можно ещё использовать и вложенную модель.
const counter = createStore(0, CounterModel);
const store = createStore({}).extends({ counter });
<Counter value={store.counter}>
Что же, счётчик не такой уж и сложный компонент, чтобы раскрыть все возможности ReactLens, но уже можно показать некоторые плюсы этой библиотеки.
Исходные тексты взяты тут.
Приятного исследования!
В следующих статьях я хочу познакомить вас с другими менеджерами состояния, которые могут предложить интересные решения.