Как сделать свой собственный менеджер состояния в React всего с одним хуком

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

В этой заметке я постараюсь разобрать решение, которое позволяет изменять состояние только тех потребителей, которые необходимо изменить. Причем такое поведение будет обеспечено всего одним хуком. Собственно, ничего нового здесь не будет. Мы просто возьмем за основу паттерн pub/sub и научим его работать с состояниями.

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

type TAtom<T> = {   state: T,   setState: React.Dispatch<React.SetStateAction<T>> }  const bus = {   channels: {} as Record<string, TAtom<any>>,   subscribe: function <T>() {},   unsubscribe: function <T>(key: string) {},   publish: function <T>(callback: Function, issuer: string, channelName: string) {},   atom: function <T>(key: string): Record<string, T> {}, };

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

function <T>(     channelName: string,     state: any,     callback: React.Dispatch<React.SetStateAction<T>>,     key: string   ) {     if (!this.channels[channelName]) {       this.channels[channelName] = {} as TAtom<T>;     }     this.channels[channelName][key as keyof TAtom<T>] = {       state,       setState: (nextState: T) => {         callback(nextState);         this.channels[channelName][key as keyof TAtom<T>].state = nextState;       },     };   },

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

function<T> (key: string) {     Object.keys(this.channels).forEach((channel: string) => {       if (this.channels[channel][key as keyof TAtom<T>]) {         delete this.channels[channel][key as keyof TAtom<T>];       }     });   }

Теперь мы можем сохранять состояния компонентов и исключить отслеживание состояния. Нам просто нужно реализовать методы, которые будут изменять эти состояния. Я предлагаю два таких метода. Один из них будет менять состояния в выбранном канале, а другой будет исключительно для подписчика, который инициирует изменения. Начнем с атомарного изменения.

function <T>(key: string): TAtom<T> {     let atom = {} as TAtom<T>;     Object.keys(this.channels).forEach((channel: string) => {       if (this.channels[channel][key as keyof TAtom<T>]) {         atom = this.channels[channel][key as keyof TAtom<T>];       }     });     const setState = (nextState: any) => {       atom.setState(nextState);       atom.state = nextState;     };     return { state: atom.state, setState };   }

Метод возвращает состояние и функцию для его изменения. Это поможет нам в будущем. А пока, сделаем метод для публикации изменений для подписчиков в выбранном канале.

function<T> (callback: Function, issuer: string, channelName: string) {     const source = Object.assign({} as TAtom<T>, this.channels[channelName]);     delete source[issuer as keyof TAtom<T>];     callback(source); },

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

type TAtom<T> = {   state: T,   setState: React.Dispatch<React.SetStateAction<T>> }  const bus = {   channels: {} as Record<string, TAtom<any>>,   subscribe: function<T> (     channelName: string,     state: T,     callback: React.Dispatch<React.SetStateAction<T>>,     key: string   ) {     if (!this.channels[channelName]) {       this.channels[channelName] = {} as TAtom<T>;     }     this.channels[channelName][key as keyof TAtom<T>] = {       state,       setState: (nextState: T) => {         callback(nextState);         this.channels[channelName][key as keyof TAtom<T>].state = nextState;       },     };   },   unsubscribe: function<T> (key: string) {     Object.keys(this.channels).forEach((channel: string) => {       if (this.channels[channel][key as keyof TAtom<T>]) {         delete this.channels[channel][key as keyof TAtom<T>];       }     });   },   // Changes state for subscribers escape issuer   publish: function<T> (callback: Function, issuer: string, channelName: string) {     const source = Object.assign({} as TAtom<T>, this.channels[channelName]);     delete source[issuer as keyof TAtom<T>];     callback(source);   },   // Gets state and callback for subscriber   atom: function<T> (key: string): TAtom<T> {     let atom = {} as TAtom<T>;     Object.keys(this.channels).forEach((channel: string) => {       if (this.channels[channel][key as keyof TAtom<T>]) {         atom = this.channels[channel][key as keyof TAtom<T>];       }     });     const setState = (nextState: any) => {       atom.setState(nextState);       atom.state = nextState;     };     return { state: atom.state, setState };   }, };

Вы можете сказать: «Все хорошо, но ты ведь обещал нам хук!». Что же, обещание не заставит себя долго ждать.

export const useBusState = <T>(   channelName: string,   state: T,   key: string = v4() ): [     T,     (callback: Function, channel: string) => void,     () => TAtom<unknown>   ] => {   const [value, setState] = useState<T>(state);   const [subscriber] = useState<string>(key);   useEffect(() => {     bus.subscribe(channelName, value, setState, subscriber);     return () => {       bus.unsubscribe(subscriber);     };   }, []);   return [     value,     (callback: Function, channel: string) =>       bus.publish(callback, subscriber, channel),     () => bus.atom(subscriber),   ]; };

Хук ожидает получить имя канала, начальное состояние и ключ подписчика в качестве входных данных. В этом случае, если ключ не установлен, он будет cгенерирован. Хук автоматически отменит подписку на компонент, если он будет размонтирован.

Давайте теперь посмотрим на хук в действии на простом примере. Нам нужно создать компонент приложения, отображающий четыре карточки.

import React from "react"; import "./App.css"; import { Card } from "./components/Card"; import { TAtom, useBusState } from "./StateBus";  function App() {   const [root, publish] = useBusState("root", "");   const handleClick = () => {     publish((channel: Record<string, TAtom<boolean>>) => {       Object.keys(channel).forEach((key) => {         if (channel[key].state === true) {           channel[key].setState(false);         }       });     }, "cards");   };   return (     <div className="App">       <Card />       <Card />       <Card />       <Card />       <button onClick={handleClick}>Erase</button>     </div>   ); }  export default App;

И сами карточки, на которые будет подписан наш новый менеджер.

import React from "react"; import { v4 } from "uuid"; import { TAtom, useBusState } from "../StateBus";  export const Card: React.FC<unknown> = () => {   const [isActive, publish, atom] = useBusState("cards", false);    const handleClick = () => {     atom().setState(true);     publish((channel: Record<string, TAtom<boolean>>) => {       Object.keys(channel).forEach((issuer) => {         if (channel[issuer].state === true) {           channel[issuer].setState(false);         }       });     }, "cards");   };    return (     <div       className={`card ${isActive && "active"}`}       key={v4()}       onClick={handleClick}     ></div>   ); }; 

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

Поменять состояние карточки можно и из другого канала. Посмотрите на обработчик события нажатия на кнопку Erase, который всем подписчикам канала сбрасывает состояние в false.

Компонент App зарегистрировался в корневом канале, но это не мешает ему взаимодействовать с состоянием карточек в канале карт. В этом случае повторный рендер произойдет только для карточки с состоянием true.

Таким образом, мы получили только один хук для хранения состояний компонентов с возможностью влиять на состояния при сохранении относительно высокой производительности.

Исходники: https://github.com/Pavloid21/state_hook_example


ссылка на оригинал статьи https://habr.com/ru/post/696660/

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *