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

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

snzathij2-4qdtsilvzshxgsx2w.png

Что такое Context API

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

wdmrrlwzq-oyukei35ufglfocfo.png

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

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

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

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

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

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

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

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

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

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

type CounterContextType = {
 number: number;
 setNumber: React.Dispatch>;
};

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

export const SimpleCount = () => {
 const [number, setNumber] = useState(0);

 const contextValue = useMemo(() => ({ number, setNumber }), [number, setNumber]);

 return (
   
     
     
   
 );
};
  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}
     
   
 );
};

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

9awfjv4s48zxdktgnv24sids0mi.png

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

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

export const Actions = memo(() => {
 const { setNumber } = useContext(CountContext);

 useRenderLog('Actions');

 return (
   <>
     
   
 );
});

Есть 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 (
   <>
     
   
 );
});

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

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

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

export const NumberContext = createContext(0);

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

export const SimpleCount = () => {
 const [number, setNumber] = useState(0);

 return (
   
     
       
       
     
   
 );
};
  1. В Actions подписываемся только на функцию setNumber:

export const Actions = memo(() => {
// Теперь мы действительно подписаны только на функцию
 const setNumber = useContext(SetNumberContext);

// При изменении number - наш компонент молчит!
 useRenderLog('Actions');

 return (
   <>
     
   
 );
});

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

kaejldse8lqvp2foxdbcupygeuq.png

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

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

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

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

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

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

export const IncrementContext = createContext({
 increment: () => {},
 decrement: () => {},
});

2. Используем новый контекст: 
```typescript
export const BottomActions = memo(() => {
 const { increment, decrement } = useContext(IncrementContext);

 useRenderLog('Actions');

 return (
   <>
     
); });
  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, добавляя новый провайдер в его рендер, чтобы все заработало:

   
     
       
         
         
         
       
     
   

Вроде бы готово, но есть небольшой нюанс — теперь 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 (
   
     
       
         
         
         
       
     
   
 );
};
  1. Чтобы это исправить, создаем новый компонент CounterProvider. Он будет почти такой же как SimpleСount, но заберет на себя всю логику работы со счетчиком. В него будем передавать только дочерние компоненты. Теперь логика отделена. Вот как это выглядит:

export const SimpleCount = () => {
 return (
   
     
     
     
   
 );
};

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

Почему? Из-за большого количества шаблонного кода (три провайдера, три контекста). Часто для удобства создают дополнительные хуки, которые используют контексты — так не придется импортировать 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 в качестве дополнительного стейт-менеджера.

ur5viop7w-33ltxsgji1cqdwl_k.png

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

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

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

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

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

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

© Habrahabr.ru