Debouncing с помощью React Hooks: хук для функций

Здравствуйте! Меня зовут Игорь Шамаев, я главный инженер по разработке в команде SmartData. Занимаюсь fullstack-разработкой внутренней аналитической BI-системы. В нашей компании React принят в качестве основного стандарта для построения пользовательских интерфейсов. Как и большая часть сообщества React, мы активно используем хуки в нашей повседневной работе.

Постоянное обучение — неотъемлемая часть работы любого хорошего разработчика. Поэтому сегодня мне хотелось бы внести свой скромный вклад в этот процесс и представить небольшое руководство для тех, кто начинает активно изучать React и работу с хуками. И попутно дать вам небольшой и полезный инструмент для работы с новым стандартом React.

В переводе статьи Debouncing с помощью React Hooks мы узнали, как можно без сторонних библиотек, используя только возможности React, создать хук в несколько строк кода для работы с отложенными изменениями значений переменных. Теперь я предлагаю рассмотреть еще один полезный хук, который поможет нам отложить вызов функции. Если функция будет вызываться много раз подряд, то фактический вызов произойдет только по прошествии установленного нами интервала задержки. То есть, только для последнего вызова из серии. Решение также очень компактное и легко реализуемое в React. Если вам стало интересно, прошу под кат.

0249-hp-iblkta_uwxxrv9clop8.png

Для опытных пользователей 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 (
    
Temperature range
); }

8n1247-yvslgpvhdiqqthxc3vye.gif

А теперь давайте представим, что нам нужно как-то обрабатывать изменившиеся значения диапазона температуры сразу после того, как пользователь прекратит двигать ползунок. Что именно мы будем делать со значением, в этом примере не так важно. Возможно, мы захотим дополнительно сохранять значение где-то еще после обработки через функцию процессинга, или просто логгировать. В целом, ограничимся абстрактной реализацией этой функции, которая просто сообщит нам актуальное значение через console.log():

export default function valueLogging(value) {
  console.log(`Request processed. Value: ${value}`);
}

Теперь добавим в handleChange() вызов нашей функции valueLogging() и посмотрим, что из этого получится:

  const handleChange = (event, newValue) => {
    setValue(newValue);
    valueLogging(newValue);
  };

ci5a-fv4szv06rre9ci2gddi5iq.gif

Хммм… это явно не то, чего мы хотим. Функция 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 (
    
Temperature range
); }

Кажется, получилось несколько многословно и не очень красиво, не правда ли? Кроме того, придётся добавлять дополнительную проверку в 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 (
    
Temperature range
); }

Нам нужно создать хук 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 мс после окончания последнего взаимодействия с ползунком. Всё довольно просто и прозрачно.


Но, подождите… зачем нам вообще useRef? К чему все эти сложности?

Мы же можем просто объявить let timeoutId; и использовать замыкание:

export default function useDebouncedFunction(func, delay) {
  let timeoutId;

  return (...args) => {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => func(...args), delay);
  };
}

Ответ кроется в самом React. При каждом новом цикле рендера React пересоздает все функции в теле компонента. Если вы попробуете применить последний пример, то он будет работать так же, как и самый первый пример:

ci5a-fv4szv06rre9ci2gddi5iq.gif

Это происходит из-за того, что каждый раз мы будем получать новую функцию debouncedValueLogging() с новой переменной timeoutId. И никакого знания о ее содержании из предыдущего рендера мы не получим.

Давайте посмотрим, как это работает:

olefzwy4h3fbz1oyooarxpv9qls.gif

Эврика! Код стал проще и кода стало меньше. Как раз то, что нам нужно.

Уже в таком варианте хук можно использовать для функций, которые на зависят от состояния монтирования компонента. Этот вариант вам вполне подходит, если вы хотите куда-то логгировать факт взаимодействия пользователя с ползунком. Нам хотелось бы логгировать это событие при каждом его возникновении. Но что если в наш хук передать функцию, которая зависит от того, монтирован компонент или нет?

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

Расширим пример и внесем следующие дополнения.

Во-первых, наш компонент будет через пропсы принимать функцию, которая передаст значение 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 (
    
      {sliderShown ? (
        
      ) : (
        Too hot!
      )}
    
  );
}

Во-вторых, в компонент 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 (
    
Temperature range
); }

В-третьих, добавим простую реализацию функции checkIfOptimal():

// Эмулируем проверку значения
export default function checkIfOptimal(newValue, setIsOptimal) {
  return setIsOptimal(10 < newValue[0] && newValue[1] < 80);
}

Посмотрим, как это будет работать:

yojyrdl0von-ao2tamcm4exsare.gif

Отлично, теперь давайте выставим значение температуры на максимум и проверим, как себя поведет наша отложенная функция checkIfOptimal().

5ijibhuvjee9jgloqjplflrjbgg.gif

Как видите, 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
  );

Проверим результат.

qbcncqkvjcv3o38hiluw6phy9os.gif

Всё работает, как мы и хотели. Факт взаимодействия пользователя с ползунком успешно логгирован, а изменение внутреннего состояния компонента отменено после его размонтирования.

О чём ещё стоит помнить?

Если мы внимательно присмотримся к нашему примеру, то увидим один очень важный нюанс. checkIfOptimal() — синхронная функция, которая будет выполнена целиком сразу после срабатывания таймера. Если checkIfOptimal() будет вести себя асинхронно, допустим, обращаться на сервер и ждать от него ответа, то снова может возникнуть ситуация с изменением состояния размонтированного компонента.

Как это может произойти? Если таймер хука useDebouncedFunction уже запустил выполнение отложенной функции, которая затем уснула в ожидании ответа сервера, то при получении ответа может случится так, что наш компонент окажется уже размонтирован.

Как за этим следить и чем еще могут быть чреваты такие отложенные асинхронные вызовы, мы поговорим уже в следующий раз.

На этом всё. Надеюсь, этот хук будет полезен в ваших проектах.

Для тех, кто хочет поиграть с примером в живую, я подготовил маленький интерактив на codesandbox. Можете поэкспериментировать:

useDebouncedFunction на codesandbox

© Habrahabr.ru