Управление временем в React

Не секрет, что такие функции как setInterval, setTimeout очень хотят быть уничтоженными при размонтировании компонента во избежание утечек памяти. И если с компонентами-классами всё понятно, то так ли очевидны способы взаимодействия с таймерами в функциональных компонентах?

Компонент-класс

В классах заботу о таймерах на себя берёт метод жизненного цикла componentWillUnmount. Объявляем таймер и оставляем его на совесть методу.

import { Component } from 'react';

class Timer extends Component {
  timer;

  // таймер срабатывает по клику
  startTimer = () => {
    this.timer = setTimeout(() => {
      console.log('start this.timer');
    }, 500);
  }

  componentWillUnmount() {
    clearTimeout(this.timer); // очистка таймера
  }

  render() {
    return <button onClick={this.startTimer}>Start timer</button>;
  }
}

Функциональный компонент

В функциональных компонентах нет возможности задать таймер как константу — это не сработает из-за особенностей рендера. Чтобы таймер запускался при каком-то пользовательском действии как в предыдущем примере, нужно привязать его через useRef.

import { useRef, useEffect } from 'react';

export const Timer = () => {
  const timerRef = useRef(null);

  // таймер срабатывает по клику
  const startTimer = () => {
    timerRef.current = setTimeout(() => {
      console.log('start current timeout');
    }, 500);
  }

  useEffect(() => {
    return () => clearTimeout(timerRef.current); // очистка таймера
  }, []);

  return <button onClick={startTimer}>Start timer</button>;
}

Если пользовательских действий не нужно, на помощь придут пользовательские хуки: с ними не придётся дублировать код таймера каждый раз.

Хук useTimeout

Аналог таймера, данного выше. Только запускаться он будет сразу после монтирования того компонента, к которому подключен.

import { useEffect, useRef } from 'react';

export function useTimeout(callback, delay) {
  // Запомнить переданную функцию обратного вызова
  const savedCallback = useRef(callback);

  // переопределять callback при необходимости
  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  // Запустить таймер
  useEffect(() => {
    // или не запустить когда не задано время задержки
    if (delay === null) return;

    const timerId = setTimeout(() => savedCallback.current(), delay);
    return () => clearTimeout(timerId);
  }, [delay])
}

Как использовать в компоненте:

// передать в качестве handleCallback функцию,
// которая должна быть вызвана по таймауту
useTimeout(handleCallback, 500);

Хук useInterval

Теперь что-нибудь поинтереснее с интервалом. На многих сайтах есть такой кейс: при вводе логина получать одноразовый пароль по SMS и, если в течение некоторого времени SMS не приходит, отправлять запрос заново. Тут и может пригодиться наш хук.

import { useState, useEffect } from 'react';

export function useInterval(time, delay) {
  // запомнить переданное время в секундах
  const [timeLeft, setTimeLeft] = useState(time);

  useEffect(() => {
    // не запускать когда не задано время задержки
  	if (delay === null) return;

  	// уменьшать время на единицу
    const tick = () => {
      setTimeLeft(timeLeft - 1);
    };

    // старт
    const timerId = setInterval(tick, delay);

    // остановить если время истекло
    if (timeLeft <= 0) clearInterval(id);

    // очистить интервал
    return () => clearInterval(timerId);
  }, [delay, timeLeft]);

  // передать управление интервалом вовне
  return [timeLeft, setTimeLeft];
}

Как использовать в компоненте:

// импорт хука
import { useInterval } from '../hooks/useInterval';

const ONE_SECOND = 1000;
const ONE_MINUTE = 60;

const formatTime = time => time > 9 ? time : `0${time}`;

export const Timeout = () => {
  // получить данные из хука
  // задать время 2 мнуты, остаток менять с интервалом в секунду
  const [timeLeft, setTimeLeft] = useInterval(ONE_MINUTE * 2, ONE_SECOND);

  // используем timeLeft для привычного отображения времени
  const minutes = formatTime(Math.floor(timeLeft / ONE_MINUTE));
  const seconds = formatTime(timeLeft - minutes * ONE_MINUTE);

  const handleStartTimer = () => {
    setTimeLeft(ONE_MINUTE);
  };

  return (
    <div>
      {
        timeLeft > 0
        ? <p>До повторной отправки осталось: {minutes}:{seconds}</p>
        : <button onClick={handleStartTimer}>Отправить SMS</button>
      }
    </div>
  )
};

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