Frontend Talks: как упростить создание контекста с помощью Constate

от автора

Привет, Хабр! Я Константин Логиновских — разработчик в Cloud.ru. Недавно я выступил на Frontend Talks с темой «Constate — контекст на стероидах». Рассказал, какой контекст захочет использовать каждый, почему Constate — это круто и полезно, а также на примере небольшого приложения показал, как с его помощью улучшить разработку. Мой доклад в письменном виде под капотом — welcome!

Что такое Context API

Context API — это способ передачи данных внутри библиотеки React от родительского компонента к дочернему без использования свойства Props. Его удобно использовать в тех случаях, когда дерево вложенности компонентов достаточно большое и нужно передавать данные напрямую от «родителя» к «внуку», «правнуку» и т. д.

Пишем приложение: постановка задачи

В качестве примера напишем небольшое приложение-счетчик с двумя основными компонентами:

  1. Сounter (отображает текущее значение счетчика) + кнопка инкремента.

  2. Кнопка «Сбросить».

Вокруг них будем строить контекст.

Пример счетчика

Пример счетчика

Создаем простой контекст: подводные камни

Напишем простой контекст, который обычно пишут все, кто пользуется контекстом в React.

  1. Создаем, типизируем и инициализируем простыми значениями:

type CounterContextType = {  number: number;  setNumber: React.Dispatch<React.SetStateAction<number>>; };  export const CountContext = createContext<CounterContextType>({  number: 0,  setNumber: () => {}, }); 
  1. Затем создаем SimpleCount — компонент всего счетчика. Он будет содержать те значения, которые мы будем передавать дочерним компонентам, а также пробрасывать их в провайдер (чтобы дочерние компоненты Actions и CountView могли его использовать).

export const SimpleCount = () => {  const [number, setNumber] = useState(0);   const contextValue = useMemo(() => ({ number, setNumber }), [number, setNumber]);   return (    <CountContext.Provider value={contextValue}>      <Actions />      <CountView />    </CountContext.Provider>  ); }; 
  1. Через функцию useСontext создаем дочерние компоненты, которые используют контекст — consumers. Они подписываются на определенные значения и их отрисовывают:

const CountView = () => { const { number, setNumber } = useContext(CountContext);   // Это функция для логирования отрисовок - поможет дальше  useRenderLog('CountView');   const increment = useCallback(() => {    setNumber((prev: number) => prev + 1);  }, [setNumber]);   return (    <>      Count View: {number}      <button onClick={increment}>Add</button>    </>  ); }; 

Итак, мы все написали, контекст работает и данные передаются. Но есть один нюанс: когда нажимаешь на любую кнопку (при работе с контекстом и при изменении значения Number), обновляется слишком много полей. При изменении счетчика почему-то обновилась кнопка «Сбросить»‎, хотя счетчик на эту кнопку не влияет.

Разбираемся в причинах проблемы

Давайте разберемся в чем проблема. В терминах React обновление — это перерисовка, то есть выполнение js-кода вокруг того jsx, который мы написали. Ниже код кнопки «Сбросить» — она обновляется, хотя подписана на функцию setNumber, которая не изменилась:

export const Actions = memo(() => {  const { setNumber } = useContext(CountContext);   useRenderLog('Actions');   return (    <>      <button onClick={() => setNumber(0)}>reset</button>    </>  ); }); 

Есть 4 причины, по которым может перерисовываться компонент в React:

  • обновление родительского компонента — не подходит т. к. от перерисовки нас защищает функция memo;

  • изменение Props — не подходит т. к. их у нас нет;

  • изменение State — не подходит т. к. его у нас нет;

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

Мы подписались только на setNumber, но на самом деле это не совсем так. В коде видно, что при деструктуризации мы подписались не только на setNumber, но и на весь контекст (при этом не используем его).

// memo здесь бесполезна, т. к. не защищает от изменений контекста export const Actions = memo(() => {  const { setNumber } = useContext(CountContext);  // Неявно - const { number, setNumber } = useContext(CountContext);   // Перерисовки при каждом изменении number  useRenderLog('Actions');   return (    <>      <button onClick={() => setNumber(0)}>reset</button>    </>  ); }); 

Исправляем проблему

Давайте это исправим — создадим два контекста и в будущем будем подписываться на них. Один из них будет хранить value, а другой функцию изменения этого value.

  1. Вносим изменения в SimpleCount. Теперь код стал немного чище:

export const NumberContext = createContext<number>(0);  export const SetNumberContext = createContext<Dispatch<SetStateAction<number>>>(() => {}); 
  1. Добавляем дополнительный провайдер и пробрасываем нужные значения:

export const SimpleCount = () => {  const [number, setNumber] = useState(0);   return (    <NumberContext.Provider value={number}>      <SetNumberContext.Provider value={setNumber}>        <Actions />        <CountView />      </SetNumberContext.Provider>    </NumberContext.Provider>  ); }; 
  1. В Actions подписываемся только на функцию setNumber:

export const Actions = memo(() => { // Теперь мы действительно подписаны только на функцию  const setNumber = useContext(SetNumberContext);  // При изменении number - наш компонент молчит!  useRenderLog('Actions');   return (    <>      <button onClick={() => setNumber(0)}>reset</button>    </>  ); }); 

Отлично, теперь обновляются только нужные компоненты:

Пишем приложение: новые вводные

Теперь представим, что дизайн счетчика решили изменить (обычная практика) и в приложении должно быть уже две кнопки: с необычным инкрементом и «Сбросить». Т. е. теперь мы работаем уже с тремя разными компонентами.

Пример счетчика с новыми вводными

Пример счетчика с новыми вводными

Что будем делать:

  1. Создаем новый контекст и вносим данные для нового компонента:

export const IncrementContext = createContext<IncrementType>({  increment: () => {},  decrement: () => {}, }); 
  1. Используем новый контекст:

export const BottomActions = memo(() => {  const { increment, decrement } = useContext(IncrementContext);   useRenderLog('Actions');   return (    <>      <br />      <button onClick={increment}>increment</button>      <button onClick={decrement}>decrement</button>    </>  ); }); 
  1. Затем создаем провайдер, который будет этот контекст использовать:

export const SimpleCount = () => {  const [number, setNumber] = useState(0);   const increment = useCallback(() => {    setNumber((prev: number) => prev + 1);  }, [setNumber]);   const decrement = useCallback(() => {    setNumber((prev: number) => prev - 1);  }, [setNumber]);   const incrementContextValue = useMemo(    () => ({      increment,      decrement,    }),    [increment, decrement],  ); // …тут рендер } 
  1. Обновляем логику SimpleСount, добавляя новый провайдер в его рендер, чтобы все заработало:

   <NumberContext.Provider value={number}>      <SetNumberContext.Provider value={setNumber}>        <IncrementContext.Provider value={incrementContextValue}>          <Actions />          <CountView />          <BottomActions />        </IncrementContext.Provider>      </SetNumberContext.Provider>    </NumberContext.Provider> 

Вроде бы готово, но есть небольшой нюанс — теперь SimpleСount не такой уж Simple. В нем содержится слишком много разной логики и эта логика перемешана с отрисовкой:

export const SimpleCount = () => {  const [number, setNumber] = useState(0);   const increment = useCallback(() => {    setNumber((prev: number) => prev + 1);  }, [setNumber]);   const decrement = useCallback(() => {    setNumber((prev: number) => prev - 1);  }, [setNumber]);   const incrementContextValue = useMemo(    () => ({      increment,      decrement,    }),    [increment, decrement],  );   return (    <NumberContext.Provider value={number}>      <SetNumberContext.Provider value={setNumber}>        <IncrementContext.Provider value={incrementContextValue}>          <Actions />          <CountView />          <BottomActions />        </IncrementContext.Provider>      </SetNumberContext.Provider>    </NumberContext.Provider>  ); }; 
  1. Чтобы это исправить, создаем новый компонент CounterProvider. Он будет почти такой же как SimpleСount, но заберет на себя всю логику работы со счетчиком. В него будем передавать только дочерние компоненты.

Теперь логика отделена. Вот как это выглядит:

export const SimpleCount = () => {  return (    <CounterProvider>      <Actions />      <CountView />      <BottomActions />    </CounterProvider>  ); }; 

Но на этом не всё. Проблема в том, что большинство разработчиков не любят так писать и используют сторонние стейт-менеджеры. Почему? Из-за большого количества шаблонного кода (три провайдера, три контекста).

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

// Наверняка вам знаком такой код export const useNumber = () => useContext(NumberContext); export const useSetNumber = () => useContext(SetNumberContext); export const useIncrement = () => useContext(IncrementContext); 

Что с этим делать? Использовать стандартную библиотеку Constate — она поможет убрать шаблонный код и сделать работу с контекстом гораздо проще (т. к. берет на себя бесполезную работу).

Исправляем с помощью Constate

Посмотрим, как Constate поможет в нашем случае.

  1. Вынесем логику счетчика в отдельный блок и назовем его useCounter. Это отдельный блок, который будет содержать только нужную логику и возвращать значение, которое мы будем дальше использовать:

export const useCounter = () => {  const [number, setNumber] = useState(0);   const increment = useCallback(() => {    setNumber((prev: number) => prev + 1);  }, [setNumber]);   const decrement = useCallback(() => {    setNumber((prev: number) => prev - 1);  }, [setNumber]);   const incrementValue = useMemo(() => ({ increment, decrement }), [increment, decrement]);   return { number, setNumber, incrementValue }; }; 
  1. Теперь возьмем функцию constate и положим в нее useCounter и селекторы:

export const [CounterProvider, useNumber, useSetNumber, useIncrement] = constate(  useCounter,  state => state.number,  state => state.setNumber,  state => state.incrementValue, ); 

Эти селекторы будут определять именованные хуки — какую именно часть исходного state нам нужно вернуть в результате его исполнения.

Таким образом мы:

  1. Сократили количество кода в 2 раза.

  2. Отделили логику от отрисовки.

  3. Получили разделение контекстов: теперь типизацией занимается библиотека.

Перспективы применения Constate и выводы

Что еще можно написать с помощью Constate? Все то, для чего раньше вы использовали обычный контекст:

  • модалки (состояние открытости, контент, управление),

  • формы (можно написать простой декоратор final-form, если не хочется его весь тащить),

  • темизацию (любой каприз, который не будет перерисовывать ваш компонент каждый тик),

  • запросы (и такое возможно — маленький стейт со своим запросом, например, конфигурации),

  • составные компоненты (сложные слайдеры со взаимозависимой логикой).

Также его можно использовать вместе с react-query в качестве дополнительного стейт-менеджера.

Итак, Constate — это очень мощный и гибкий инструмент разработки. Основные преимущества Constate:

  • размер: весит меньше одного КБ (всего 90 строк вместе с документацией и комментариями);

  • универсальность: подходит для разных задач и проектов;

  • автоматизация: контекст можно экспортировать одной строкой, а не вручную.


Видео доклада Константина Логиновских на YouTube-канале Cloud.ru Tech. Подписывайтесь!

Что ещё интересного есть в блоге:


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