Здравствуйте! Меня зовут Игорь Шамаев, я главный инженер по разработке в команде SmartData. Занимаюсь fullstack-разработкой внутренней аналитической BI-системы. В нашей компании React принят в качестве основного стандарта для построения пользовательских интерфейсов. Как и большая часть сообщества React, мы активно используем хуки в нашей повседневной работе.
Постоянное обучение — неотъемлемая часть работы любого хорошего разработчика. Поэтому сегодня мне хотелось бы внести свой скромный вклад в этот процесс и представить небольшое руководство для тех, кто начинает активно изучать React и работу с хуками. И попутно дать вам небольшой и полезный инструмент для работы с новым стандартом React.
В переводе статьи Debouncing с помощью React Hooks мы узнали, как можно без сторонних библиотек, используя только возможности React, создать хук в несколько строк кода для работы с отложенными изменениями значений переменных. Теперь я предлагаю рассмотреть еще один полезный хук, который поможет нам отложить вызов функции. Если функция будет вызываться много раз подряд, то фактический вызов произойдет только по прошествии установленного нами интервала задержки. То есть, только для последнего вызова из серии. Решение также очень компактное и легко реализуемое в React. Если вам стало интересно, прошу под кат.
Для опытных пользователей React сразу привожу код хука под спойлером. Можете просто ознакомиться и забрать для использования. Для тех, кто не так хорошо знаком с React и хуками, я подготовил подробный разбор на примере использования этого хука.
import { useRef, useEffect } from "react";
export default function useDebouncedFunction(func, delay, cleanUp = false) {
const timeoutRef = useRef();
function clearTimer() {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = undefined;
}
}
useEffect(() => (cleanUp ? clearTimer : undefined), [cleanUp]);
return (...args) => {
clearTimer();
timeoutRef.current = setTimeout(() => func(...args), delay);
};
}
Итак, давайте рассмотрим какой-то реальный пример, когда нам будет удобно использовать отложенный вызов функции при её многократном вызове. Допустим, у нас есть ползунок диапазона температуры. Для наглядности и простоты прибегнем к помощи библиотеки компонентов Material-UI.
import React from "react";
import { makeStyles, Typography, Slider } from "@material-ui/core";
import useDebouncedFunction from "./useDebouncedFunction";
import apiRequest from "./apiRequest";
const useStyles = makeStyles({
root: {
width: 300
}
});
function valuetext(value) {
return `${value}°C`;
}
export default function RangeSlider() {
const classes = useStyles();
const [value, setValue] = React.useState([20, 37]);
const handleChange = (event, newValue) => {
setValue(newValue);
};
return (
<div className={classes.root}>
<Typography id="range-slider" gutterBottom>
Temperature range
</Typography>
<Slider
value={value}
onChange={handleChange}
valueLabelDisplay="auto"
aria-labelledby="range-slider"
getAriaValueText={valuetext}
/>
</div>
);
}
А теперь давайте представим, что нам нужно как-то обрабатывать изменившиеся значения диапазона температуры сразу после того, как пользователь прекратит двигать ползунок. Что именно мы будем делать со значением, в этом примере не так важно. Возможно, мы захотим дополнительно сохранять значение где-то еще после обработки через функцию процессинга, или просто логгировать. В целом, ограничимся абстрактной реализацией этой функции, которая просто сообщит нам актуальное значение через console.log()
:
export default function valueLogging(value) {
console.log(`Request processed. Value: ${value}`);
}
Теперь добавим в handleChange()
вызов нашей функции valueLogging()
и посмотрим, что из этого получится:
const handleChange = (event, newValue) => {
setValue(newValue);
valueLogging(newValue);
};
Хммм… это явно не то, чего мы хотим. Функция valueLogging()
вызывается при каждом изменении значения ползунков. Это слишком часто. А нам нужно только последнее актуальное значение сразу после окончания взаимодействия пользователя с ползунком. Как мы можем поступить?
Вариант 1. Использовать хук useDebounce
и следить за изменением переменной value
в теле компонента через хук.
Для этого у нас будет возвращаемое хуком useDebounce
значение debouncedValue
, которое будет меняться только по прошествии установленного нами интервала с момента прекращения взаимодействия пользователя с ползунком. И уже с помощью хука useEffect
мы вызывали бы valueLogging()
. Получилось бы что-то вроде этого:
export default function RangeSlider() {
const classes = useStyles();
const [value, setValue] = React.useState([20, 37]);
const [changedByUser, setChangedByUser] = React.useState(false);
const debouncedValue = useDebounce(value, 300);
useEffect(() => {
if (changedByUser) {
valueLogging(debouncedValue);
}
}, [debouncedValue]);
const handleChange = (event, newValue) => {
setValue(newValue);
if (!changedByUser) {
setChangedByUser(true);
}
};
return (
<div className={classes.root}>
<Typography id="range-slider" gutterBottom>
Temperature range
</Typography>
<Slider
value={value}
onChange={handleChange}
valueLabelDisplay="auto"
aria-labelledby="range-slider"
getAriaValueText={valuetext}
/>
</div>
);
}
Кажется, получилось несколько многословно и не очень красиво, не правда ли? Кроме того, придётся добавлять дополнительную проверку в useEffect
, чтобы убедиться, что valueLogging()
не вызовется при задании значения по умолчанию. Нас интересуют только изменения, сделанные пользователем. Без этой проверки, когда useEffect
отработает при монтировании компонента, будет лишний вызов valueLogging()
. К тому же, мы обманываем React и не добавляем в зависимости useEffect
переменную changedByUser
. Если вы используете линтер, он обязательно отругает вас за это.
Какой еще у нас есть вариант?
Вариант 2. Отложить вызов valueLogging()
прямо внутри handleChange()
.
Давайте пофантазируем, как бы это могло выглядеть в готовом варианте:
export default function RangeSlider() {
const classes = useStyles();
const [value, setValue] = React.useState([20, 37]);
const debouncedValueLogging = useDebouncedFunction(valueLogging, 300);
const handleChange = (event, newValue) => {
setValue(newValue);
debouncedValueLogging(newValue);
};
return (
<div className={classes.root}>
<Typography id="range-slider" gutterBottom>
Temperature range
</Typography>
<Slider
value={value}
onChange={handleChange}
valueLabelDisplay="auto"
aria-labelledby="range-slider"
getAriaValueText={valuetext}
/>
</div>
);
}
Нам нужно создать хук useDebouncedFunction
, который примет в качестве аргументов нашу функцию и задержку её фактического вызова.
Попробуем сделать это средствами самого React:
import { useRef } from "react";
export default function useDebouncedFunction(func, delay) {
const ref = useRef(null);
return (...args) => {
clearTimeout(ref.current);
ref.current = setTimeout(() => func(...args), delay);
};
}
Это всё, что нам нужно. Давайте подробнее разберемся, что же здесь происходит. Во-первых, мы используем хук useRef
(документация по useRef
). В двух словах: хук возвращает нам своеобразный контейнер, ссылка на который остается постоянной между циклами перерисовки компонентов React. И в этот контейнер мы можем положить что-то, к чему хотели бы иметь стабильный доступ на протяжении всего цикла жизни компонента. Это значение мы можем найти или перезаписать в свойстве current
возвращаемого контейнера.
Многие, начиная пользоваться useRef
, полагают, что он предназначен только для работы со ссылками на DOM-элементы. На самом деле, возможности этого хука намного шире. Он может хранить для нас любые объекты.
Далее мы декорируем передаваемую функцию с помощью setTimeout()
с заданной нами задержкой. timeoutId
, возвращаемый функцией setTimeout()
, мы аккуратно кладем в наш контейнер ref.current
. И всё это сверху оборачиваем в еще одну функцию, которую и вернет наш хук useDebouncedFunction
. Каждый новый вызов этой функции сначала возьмет из контейнера timeoutId
и вызовет с ним clearTimeout()
. Таким образом, мы отменяем предыдущий вызов переданной в хук функции и заменяем его новым. В итоге, фактический вызов функции valueLogging()
произойдет только через 300 мс после окончания последнего взаимодействия с ползунком. Всё довольно просто и прозрачно.
Мы же можем просто объявить let timeoutId;
и использовать замыкание:
export default function useDebouncedFunction(func, delay) {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func(...args), delay);
};
}
Ответ кроется в самом React. При каждом новом цикле рендера React пересоздает все функции в теле компонента. Если вы попробуете применить последний пример, то он будет работать так же, как и самый первый пример:
Это происходит из-за того, что каждый раз мы будем получать новую функцию debouncedValueLogging()
с новой переменной timeoutId
. И никакого знания о ее содержании из предыдущего рендера мы не получим.
Давайте посмотрим, как это работает:
Эврика! Код стал проще и кода стало меньше. Как раз то, что нам нужно.
Уже в таком варианте хук можно использовать для функций, которые на зависят от состояния монтирования компонента. Этот вариант вам вполне подходит, если вы хотите куда-то логгировать факт взаимодействия пользователя с ползунком. Нам хотелось бы логгировать это событие при каждом его возникновении. Но что если в наш хук передать функцию, которая зависит от того, монтирован компонент или нет?
Представим себе ситуацию, что наш компонент слайдера может быть размонтирован при достижении какого-то условия. И проверим, как себя поведет отложенная функция, которая будет зависеть от этого состояния.
Расширим пример и внесем следующие дополнения.
Во-первых, наш компонент будет через пропсы принимать функцию, которая передаст значение value
родительскому компоненту. А родительский компонент, в свою очередь, размонтирует наш компонент RangeSlider
при выставлении на слайде максимальной температуры.
import React, { useState } from "react";
import { ThemeProvider, createMuiTheme, Typography } from "@material-ui/core";
import RangeSlider from "./RangeSlider";
const theme = createMuiTheme({});
export default function App() {
const [sliderShown, setSliderShown] = useState(true);
// размонтируем компонент при выставлении максимальной температуры
function handleValueChange(value) {
if (value[1] === 100) {
setSliderShown(false);
}
}
return (
<ThemeProvider theme={theme}>
{sliderShown ? (
<RangeSlider onValueChange={handleValueChange} />
) : (
<Typography variant="h2">Too hot!</Typography>
)}
</ThemeProvider>
);
}
Во-вторых, в компонент RangeSlider
мы добавим функцию, которая будет эмулировать проверку значения и возвращать ответ, который скажет нам о том, находится ли выставленное значение в допустимом диапазоне или в критическом. Представим, что допустимый диапазон может меняться в зависимости от каких-то дополнительных условий, о которых знает эта функция, и что нам нужно проверять только те значения, которые окончательно выставил пользователь. А в качестве результата мы будем менять цвет с зеленого на красный и обратно.
import React from "react";
import { makeStyles, Typography, Slider } from "@material-ui/core";
import useDebouncedFunction from "./useDebouncedFunction";
import valueLogging from "./valueLogging";
import checkIfOptimal from "./checkIfOptimal";
const useStyles = makeStyles({
root: {
width: 300
}
});
function valuetext(value) {
return `${value}°C`;
}
export default function RangeSlider(props) {
const classes = useStyles();
const [value, setValue] = React.useState([20, 37]);
const [isOptimal, setIsOptimal] = React.useState(true);
// Отложенное логирование
const debouncedValueLogging = useDebouncedFunction(
newValue => valueLogging(newValue),
300
);
// Отложенная проверка значения
const debouncedValueCheck = useDebouncedFunction(
newValue => checkIfOptimal(newValue, setIsOptimal),
300
);
const handleChange = async (event, newValue) => {
setValue(newValue);
debouncedValueLogging(newValue);
debouncedValueCheck(newValue);
if (props.onValueChange) {
props.onValueChange(newValue);
}
};
return (
<div className={classes.root}>
<Typography id="range-slider" gutterBottom>
Temperature range
</Typography>
<Slider
value={value}
onChange={handleChange}
valueLabelDisplay="auto"
aria-labelledby="range-slider"
getAriaValueText={valuetext}
style={{ color: isOptimal ? "#4caf50" : "#f44336" }}
/>
</div>
);
}
В-третьих, добавим простую реализацию функции checkIfOptimal()
:
// Эмулируем проверку значения
export default function checkIfOptimal(newValue, setIsOptimal) {
return setIsOptimal(10 < newValue[0] && newValue[1] < 80);
}
Посмотрим, как это будет работать:
Отлично, теперь давайте выставим значение температуры на максимум и проверим, как себя поведет наша отложенная функция checkIfOptimal()
.
Как видите, React выбрасывает следующее предупреждение:
Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.
in RangeSlider (at App.js:20)
Почему это происходит? Наша отложенная функция принимает в качестве второго аргумента коллбэк, который установит результат проверки true/false
во внутреннем состоянии компонента. Для этого мы передаем туда функцию setIsOptimal()
. В нашем примере это событие произойдет не ранее, чем через 300 мс. К этому моменту наш компонент уже размонтирован и никакого состояния у него уже нет. Об этом и предупреждает нас React. Это чревато утечками памяти. Как мы можем исправить ситуацию?
Модифицируем наш хук useDebouncedFunction
: добавим в него флаг cleanUp
. Если он будет выставлен в true
, то мы будем подчищать последний выставленный таймер при размонтировании компонента.
import { useRef, useEffect } from "react";
export default function useDebouncedFunction(func, delay, cleanUp = false) {
const timeoutRef = useRef();
// Очистка таймера
function clearTimer() {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = undefined;
}
}
// Очищаем таймер при анмаунте компонента, если cleanUp выставлен в true
// и тем самым отменяем последний запланированный вызов
useEffect(() => (cleanUp ? clearTimer : undefined), [cleanUp]);
return (...args) => {
clearTimer();
timeoutRef.current = setTimeout(() => func(...args), delay);
};
}
ХукuseEffect
в момент размонтирования компонента вызывает функцию, которую мы возвращаем из тела этого хука. В нашем случае будет вызванаclearTimer()
и последний запланированный отложенный вызов будет отменен.
Передадим новый флаг в вызов нашего хука.
// Отложенная проверка значения
const debouncedValueCheck = useDebouncedFunction(
newValue => checkIfOptimal(newValue, setIsOptimal),
300,
true
);
Проверим результат.
Всё работает, как мы и хотели. Факт взаимодействия пользователя с ползунком успешно логгирован, а изменение внутреннего состояния компонента отменено после его размонтирования.
О чём ещё стоит помнить?
Если мы внимательно присмотримся к нашему примеру, то увидим один очень важный нюанс. checkIfOptimal()
— синхронная функция, которая будет выполнена целиком сразу после срабатывания таймера. Если checkIfOptimal()
будет вести себя асинхронно, допустим, обращаться на сервер и ждать от него ответа, то снова может возникнуть ситуация с изменением состояния размонтированного компонента.
Как это может произойти? Если таймер хука useDebouncedFunction
уже запустил выполнение отложенной функции, которая затем уснула в ожидании ответа сервера, то при получении ответа может случится так, что наш компонент окажется уже размонтирован.
Как за этим следить и чем еще могут быть чреваты такие отложенные асинхронные вызовы, мы поговорим уже в следующий раз.
На этом всё. Надеюсь, этот хук будет полезен в ваших проектах.
Для тех, кто хочет поиграть с примером в живую, я подготовил маленький интерактив на codesandbox. Можете поэкспериментировать:
mayorovp
Это не React пересоздаёт, это JavaScript так работает.
CodeShaman Автор
Спасибо за комментарий. Опыт показывает, что некоторые начинающие работать с React не всегда очевидно понимают этот момент и какое-то время уходит, чтобы разобраться именно в этом аспекте. Это работает не для всех функций в теле компонента, что может сбивать с толку на первых порах. Допустим, возвращаемая хуком useState функция-сеттер гарантированно остается стабильной между циклами ререндера. React осознанно реализует это поведение с пересозданием функций и правильное использование того же хука useCallback так же требует некоторого понимания деталей.
Carduelis
А еще
useCallback
иuseMemo
не гарантируют 100% мемоизацию.Я как раз столкнулся с задачей объяснить новичкам React. Хуки с одной стороны как простые магические коробки, с другой — сложный для понимания механизм. А потом еще и складывается ощущение, что хуки можно применять и вне реакта. А там вообще ничего реактивного нет. Как быть? Как объяснить? Спасибо)
CodeShaman Автор
Хуки — это некоторый сдвиг парадигмы мышления. Особенно, если уже был некоторый опыт работы с компонентами на классах или React начинали изучать с них, а уже потом принялись за хуки. Или же в целом изучающий более привычен к понимаю классическоого и повсеместно преподаваемого ООП на классах.
А ведь конечном итоге мы имеем всегда одни и те же основные цели — получить удобный способ переиспользования кода, удобную и прозрачную работу с состояниям компонента и понятные механизмы управления эффектами.
Как известно, React и его создатели очень много вдохновения черпают в OCaml и любят функциональный подход. В обсуждениях вопроса о том, на сколько функциональны хуки сломано не мало копий. Кто-то называет это чистой процедурщиной. Кто-то с этим не согласен. Уходить в эти дебри сразу с самого начала объяснения хуков — точно не стоит.
Как говорил Кхал Дрого из известного сериала: "Это вопрос для мудрых мужчин с тощими руками." :)
Мне кажется, для начального понимания хуков нужно еще раз освежить в голове изучающих такую концепцию, как композиция. С этой точки зрения хуки — очень понятная идея. Мы берем функцию и в нее страиваем другие функции. Или выносим часть переиспользуемого функционала в отдельные хуки. По сути те же функции, но с особенностями. А их в свою очередь переиспользуем в других функциях. Хуки, в свою очередь, отличаются от обычных функций тем, что могут использовать внутренние механизмы React.
Это отражено в примере из статьи. Есть соблазн создать обычную функцию, использовать замыкания и сделать тот самый debounce. Ведь сама по себе задача уже давно известная и хорошо изученная, даже тривиальная. Но, как мы видим, напрямую в лоб оно так не заработает. Для реализации debounce мы прибегаем к внутренним механизмам React: узнаем о состоянии монтирования компонента, просим React сохранить какой-то объект в течении всего цикла жизни компонента.
И из вышесказанного можно уже подвести к тому, что хуки вне React работать не будут. Их отличие от обычных функций, которые мы можем встроить в другие функции — именно в том, что они используют возможности самого React внутри себя. А без React нет и его возможностей. :)