Frontend Talks: как упростить создание контекста с помощью Constate
Привет, Хабр! Я Константин Логиновских — разработчик в Cloud.ru. Недавно я выступил на Frontend Talks с темой «Constate — контекст на стероидах». Рассказал, какой контекст захочет использовать каждый, почему Constate — это круто и полезно, а также на примере небольшого приложения показал, как с его помощью улучшить разработку. Мой доклад в письменном виде под капотом — welcome!
Что такое Context API
Context API — это способ передачи данных внутри библиотеки React от родительского компонента к дочернему без использования свойства Props. Его удобно использовать в тех случаях, когда дерево вложенности компонентов достаточно большое и нужно передавать данные напрямую от «родителя» к «внуку», «правнуку» и т. д.
Пишем приложение: постановка задачи
В качестве примера напишем небольшое приложение-счетчик с двумя основными компонентами:
Сounter (отображает текущее значение счетчика) + кнопка инкремента.
Кнопка «Сбросить».
Вокруг них будем строить контекст.
Пример счетчика
Создаем простой контекст: подводные камни
Напишем простой контекст, который обычно пишут все, кто пользуется контекстом в React.
Создаем, типизируем и инициализируем простыми значениями:
type CounterContextType = {
number: number;
setNumber: React.Dispatch>;
};
export const CountContext = createContext({
number: 0,
setNumber: () => {},
});
Затем создаем SimpleCount — компонент всего счетчика. Он будет содержать те значения, которые мы будем передавать дочерним компонентам, а также пробрасывать их в провайдер (чтобы дочерние компоненты Actions и CountView могли его использовать).
export const SimpleCount = () => {
const [number, setNumber] = useState(0);
const contextValue = useMemo(() => ({ number, setNumber }), [number, setNumber]);
return (
);
};
Через функцию 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), обновляется слишком много полей. При изменении счетчика почему-то обновилась кнопка «Сбросить», хотя счетчик на эту кнопку не влияет.
Разбираемся в причинах проблемы
Давайте разберемся в чем проблема. В терминах 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.
Вносим изменения в SimpleCount. Теперь код стал немного чище:
export const NumberContext = createContext(0);
export const SetNumberContext = createContext>>(() => {});
Добавляем дополнительный провайдер и пробрасываем нужные значения:
export const SimpleCount = () => {
const [number, setNumber] = useState(0);
return (
);
};
В Actions подписываемся только на функцию setNumber:
export const Actions = memo(() => {
// Теперь мы действительно подписаны только на функцию
const setNumber = useContext(SetNumberContext);
// При изменении number - наш компонент молчит!
useRenderLog('Actions');
return (
<>
>
);
});
Отлично, теперь обновляются только нужные компоненты:
Пишем приложение: новые вводные
Теперь представим, что дизайн счетчика решили изменить (обычная практика) и в приложении должно быть уже две кнопки: с необычным инкрементом и «Сбросить». Т. е. теперь мы работаем уже с тремя разными компонентами.
Пример счетчика с новыми вводными
Что будем делать:
Создаем новый контекст и вносим данные для нового компонента:
export const IncrementContext = createContext({
increment: () => {},
decrement: () => {},
});
2. Используем новый контекст:
```typescript
export const BottomActions = memo(() => {
const { increment, decrement } = useContext(IncrementContext);
useRenderLog('Actions');
return (
<>
>
);
});
Затем создаем провайдер, который будет этот контекст использовать:
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],
);
// …тут рендер
}
Обновляем логику 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 (
);
};
Чтобы это исправить, создаем новый компонент 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 поможет в нашем случае.
Вынесем логику счетчика в отдельный блок и назовем его 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 };
};
Теперь возьмем функцию constate и положим в нее useCounter и селекторы:
export const [CounterProvider, useNumber, useSetNumber, useIncrement] = constate(
useCounter,
state => state.number,
state => state.setNumber,
state => state.incrementValue,
);
Эти селекторы будут определять именованные хуки — какую именно часть исходного state нам нужно вернуть в результате его исполнения.
Таким образом мы:
Сократили количество кода в 2 раза.
Отделили логику от отрисовки
Получили разделение контекстов: теперь типизацией занимается библиотека.
Перспективы применения Constate и выводы
Что еще можно написать с помощью Constate? Все то, для чего раньше вы использовали обычный контекст:
модалки (состояние открытости, контент, управление),
формы (можно написать простой декоратор final-form, если не хочется его весь тащить),
темизацию (любой каприз, который не будет перерисовывать ваш компонент каждый тик),
запросы (и такое возможно — маленький стейт со своим запросом, например, конфигурации),
составные компоненты (сложные слайдеры со взаимозависимой логикой).
Также его можно использовать вместе с react-query в качестве дополнительного стейт-менеджера.
Итак, Constate — это очень мощный и гибкий инструмент разработки. Основные преимущества Constate:
размер: весит меньше одного КБ (всего 90 строк вместе с документацией и комментариями);
универсальность: подходит для разных задач и проектов;
автоматизация: контекст можно экспортировать одной строкой, а не вручную.
Видео доклада Константина Логиновских на YouTube-канале Cloud.ru Tech. Подписывайтесь!
Что ещё интересного есть в блоге: