Привет, Хабр! Я Константин Логиновских — разработчик в 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: () => {},
});

2. Используем новый контекст: 
```typescript
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. Подписывайтесь!

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

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