В этой статье разберем как с помощью useEffect воспроизвести методы жизненного цикла в функциональных компонентах. Поговорим как и когда использовать useEffect и useLayoutEffect. Обсудим особенности отправки запросов из useEffect. Также узнаем почему массив зависимостей необязателен и когда это может пригодиться.

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

Методы жизненного цикла и api хука useEffect

Хук useEffect способен воссоздать методы componentDidMount, componentWillUnmount, componentDidUpdate и добавляет новый метод, который можно было бы назвать componentWillUpdate, однако этот хук асинхронный и будет вызван после рендеринга. Хук useLayoutEffect воссоздает те же методы, но до этапа рендеринга, благодаря чему полностью соответствует методам componentDidMountи componentDidUpdate, ниже поговорим подробнее.

Хуки useEffect и useLayoutEffect имеют одинаковое api потому, все примеры будут показаны с использованием useEffect, о различиях этих хуков будет написано отдельно.

Взглянем на api хука useEffect

function useEffect(effect: EffectCallback, deps?: DependencyList): void;

type DependencyList = ReadonlyArray<any>;
type EffectCallback = () => (void | (() => void | undefined));

useEffect как и любой другой хук - это функция. Принимает 2 аргумента, последний не обязателен:

  1. Эффект (effect) - это функция, внутри которой происходит работа с обновленными данными. Эта функция может вернуть другую функцию (cleanup), внутри которой происходит работа с данными до обновления.

  2. Массив зависимостей.

Когда любое значение из массива зависимостей изменится, вызовется функция effect, что соотвествует методу componentDidUpdate. Также effect будет вызван при монтировании компонента, что соответствует componentDidMount. Функция cleanup будет вызвана при размонтировании компонента (componentWillUnmount), либо до обновления любого значения из массива зависимостей (аналогов в классовых компонентах, можно назвать componentWillUpdate).

// Монтирование
useEffect(() => {
	// этот код будет выполнен при монтировании компонента (componentDidMount)
}, []);

// Размонтирование
useEffect(() => {
  return () => {
  	// этот код будет выполнен при размонтировании компонента (componentWillUnmount)
  }
}, []);

// Монтирование и размонтирование
useEffect(() => {
	// этот код будет выполнен при монтировании компонента (componentDidMount)
  
  return () => {
  	// этот код будет выполнен при размонтировании компонента (componentWillUnmount)
  }
}, []);


// Монтирование, размонтирование и обновление
useEffect(() => {
	// этот код будет выполнен при монтировании компонента (componentDidMount)
  // а также после обновления любого элемента из массива зависимостей (componentDidUpdate)
  
  return () => {
  	// этот код будет выполнен при размонтировании компонента (componentWillUnmount)
    // а также до обновления любого элемента из массива зависимостей (componentWillUpdate)
  }
}, [dep1, dep2]);

Когда использовать useEffect, базовый случай

Первый и самый естественный случай: использовать useEffect для "подписки" на изменения какой-либо переменной, например, состояния или пропса.

import React, { useState, FC, useEffect } from "react";

export const UseEffectExample: FC = ({ someProp }) => {
  const [state, setState] = useState(true);

  useEffect(() => {
    if (state || someProp) alert('Вывожу какое-то сообщение');
  }, [state, someProp]);

  return (
    <div>
      <button type="button" onClick={() => setState((v) => !v)}>
        toggle
      </button>
      {state.toString()}
    </div>
  );
};

Обработка событий не react компонентов

Другой случай, когда мы хотим повесить обработчик на что-то кроме react элементов. Например, установить обработчик mousemove на window, это может быть полезно, когда мы создаем кастомный инпут диапозона или элемент перемещаемый по экрану.

Для этого нужно при монтировании компонента добавить обработчик, а при размонтировании снять, чтобы если компонент будет смонтирован снова не происходила двойная обработка. В useEffect нужно передать пустой массив зависимостей, в effect добавить обработчик, а снять в cleanup (функция, которую возвращает effect).

import React, { FC, useEffect } from "react";

export const UseEffectExample: FC = () => {
  useEffect(() => {
    // Обратите внимание, обработчик можно создать прямо в эффекте, 
    // это будет работать правильно
    const fn = () => console.log('handle mousemove');
    
    // добавим обработчик при монтировании компонента
    window.addEventListener('mousemove', fn);
    
    return () => {
      // это cleanup здесь мы заметаем следы от наших предыдущих действий
      // удалим обработчик при размонтировании компонента
      window.removeEventListener('mousemove', fn);
    };
  // Обратите внимание, что массив зависимостей пустой,
  // Значит эффект сработает строго при монтировании/размонтировании
  }, []);

  return (
    <div>
      ...
    </div>
  );
};

Когда использовать cleanup. Новый метод "componentWillUpdate"?

Представьте, что мы создаем компонент отображения текущего времени NowView. Можно обновлять время каждую секунду, а можно раз в минуту, поэтому создаем пропс interval, - это интервал, с которым будет обновлять время.

При монтировании компонента запускаем интервал, при изменении/размонтировании, останавливаем интервал, для этого в effect запускаем интервал (const intervalId = setInterval), а в cleanup останавливаем (clearInterval(intervalId)).

Теперь представьте ситуацию, когда у компонента динамически изменился интервал. Обработаем это с помощью useEffect.

  1. Нужно добавить interval в массив зависимостей useEffect;

  2. Нужно остановить предыдущий интервал, иначе количество интервалов будет только увеличиваться.

    Теоретически перед const intervalId = setInterval достаточно добавить clearInterval(intervalId).
    Практически ничего делать не нужно: cleanup будет вызван перед каждым обновлением зависимостей. При изменении intervalвызовется сначала cleanup, он остановит существующий интервал, а потом сработает effect и запустит новый интервал.

import React, { FC, useEffect, useState } from "react";

type NowViewProps = {
	interval: number;
};

export const NowView: FC<NowViewProps> = ({ interval }) => {
  const [time, setTime] = useState(new Date().toISOString());
  useEffect(() => {
    // При монтировании/обновлении запускаем интервал
    const intervalId = setInterval(() => {
      setTime(new Date().toISOString())
    }, interval);
    
    return () => {
      // При размонтировании/до обновления interval останавливаем
      // существующий интервал
      clearInterval(intervalId);
    };
  }, [interval]);

  return (
    <div>
    	{time}
    </div>
  );
};

Подписка/отписка и useEffect

Также cleanup удобен в работе с subscribe/unsubscribe. Разберем на примере redux store.subscribe, это академический пример и понадобится, вероятно, в реализации своих кастомных хуков. В остальном используйте официальный хук useSelector или hoc connect. Однако паттерн subscribe/unsubscribe может быть полезен для решения других задач.

У store есть метод subscribe, в который передаем функцию обработчик состояния, и который возвращает функцию отписки от прослушивания изменений состояния unsubscribe.

import { useEffect } from "react";
import { store } from 'src/my-custom-path/store';

// Unsubscribe: () => void
// Subscribe: () => Unsubscribe;

// Кастомный хук, который принимает слушателя (listener)
// слушатель будет вызваться при каждом изменении хранилища данных (store)
export const useListenStore = (listener: () => void) => {
  // подробный вариант 1
  useEffect(() => {
  	const unsubscribe = store.subscribe(listener);
		return () => unsubscribe();
	}, [listener]);

  // подробный вариант 2
  useEffect(() => {
  	const unsubscribe = store.subscribe(listener);
		return unsubscribe;
	}, [listener]);


  // подробный вариант 3
  useEffect(() => {
		return store.subscribe(listener);
	}, [listener]);

  // чистый вариант
  useEffect(() => store.subscribe(listener), [listener]);
};

В этом примере результат вызова subscribe - это функция, которую useEffect будет использовать в качестве cleanup. При изменении listener хук useEffect вызовет cleanup, а она - это и есть unsubscribe. Так с использованием useEffect получаем механизм подписки/отписки в одну строку.

Отправка запросов с помощью useEffect

Для отправки запросов можно использовать готовые решения, такие как react-query, или даже подход, при котором все запросы производятся в санках (thunk) или сагах (sagas).

Однако можно написать свое решение. Ниже приведена реализация запроса на основе fetch. В ней есть проблема, подумайте что не так.

import { useEffect, useState } from "react";
import { store } from 'src/my-custom-path/store';

type MyQueryResponse = { data: any; error: Error; loading: boolean }; 

export const useMyQuery = (url: string, params?: RequestInit): MyQueryResponse => {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [data, setData] = useState(null);
  
  useEffect(() => {
    // устанавливаем состояние загрузки
    setLoading(true);
    
    // отправляем запрос
  	fetch(url, params)
    	// полученный ответ устанавливаем как данные
    	.then(res => setData(res))
    	// в случае ошибки устанавливаем ее
    	.catch(err => setError(err))
    	// в любом случае (успех/ошибка) убираем состояние загрузки
    	.finally(() => setLoading(false));
  }, [url, params]);
  
  // информацию об ошибке, загрузке и данные отправляем наружу
  return { data, error, loading };
};

Если в момент отправки запроса будет изменен url или параметры запроса, отправится новый запрос. И когда будет получен ответ от предыдущего запроса состояние изменится соответственно, что может привести к ошибкам.

Чтобы избежать этих проблем, можно создать вспомогательную переменную updated = false и в cleanup устанавливать ее значение равным true, а перед изменением состояния проверять эту переменную и если она true, не изменять состояние. Но это не самое чистое решение. Догадаетесь, какое решение чище?

import { useEffect, useState } from "react";
import { store } from 'src/my-custom-path/store';

type MyQueryResponse = { data: any; error: Error; loading: boolean }; 

export const useMyQuery = (url: string, params?: RequestInit): MyQueryResponse => {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [data, setData] = useState(null);
  
  useEffect(() => {
    // через замыкание останется в памяти и будет доступна
    // в методах then, catch и finally
    let updated = false;
    // устанавливаем состояние загрузки
    setLoading(true);
    
    // отправляем запрос
  	fetch(url, params)
    	// полученный ответ устанавливаем как данные
    	.then(res => {
  	    if (!updated) setData(res)
    	})
    	// в случае ошибки устанавливаем ее
    	.catch(err => {
      	if (!updated) setError(err)
    	})
    	// в любом случае (успех/ошибка) убираем состояние загрузки
    	.finally(() => {
      	if (!updated) setLoading(false)
    	});
    
    return () => {
    	updated = true;
    }
  }, [url, params]);
  
  // информацию об ошибке, загрузке и данные отправляем наружу
  return { data, error, loading };
};

Самое чистое решение в случае с fetch - использовать AbortController. Но будьте осторожны, не все браузеры его поддерживают.

import { useEffect, useState } from "react";
import { store } from 'src/my-custom-path/store';

type MyQueryResponse = { data: any; error: Error; loading: boolean }; 

export const useMyQuery = (url: string, params: RequestInit = {}): MyQueryResponse => {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [data, setData] = useState(null);
  
  useEffect(() => {
    const controller = new AbortController();
    // устанавливаем состояние загрузки
    setLoading(true);
    
 		// отправляем запрос
  	fetch(url, { ...params, signal: controller.signal })
    	// полученный ответ устанавливаем как данные
    	.then(res => setData(res))
    	// в случае ошибки устанавливаем ее
    	.catch(err => setError(err))
    	// в любом случае (успех/ошибка) убираем состояние загрузки
    	.finally(() => setLoading(false));
    
    return () => {
      // в случае изменения параметров до нового запроса предотвращаем старый запрос
    	controller.abort();
    }
  }, [url, params]);
  
  // информацию об ошибке, загрузке и данные отправляем наружу
  return { data, error, loading };
};

Когда использовать useLayoutEffect

Хук useLayoutEffect имеет такое же api как и useEffect. Отличия:

  • useLayoutEffect - синхронный и будет вызван до того, как браузер сможет отрисовать компоненты. Срабатывает когда компоненты уже находятся на virtual dom (в памяти и можно прочитать/установить различные свойств), но еще не были отрисованы браузером.

  • useEffect - асинхронный и будет вызван после того, как браузер отрисует компоненты.

Хук useLayoutEffect предотвращает лишнее обновление компонента.

import React, { FC, useEffect, useLayotEffect, useState } from "react";

type Props = {
	level: number;
}

export const UseEffectExample: FC<Props> = ({ level }) => {
  const [value, setValue] = useState(0);

  useEffect(() => {
    // Изменение состояния будет происходить после выполнения всего кода
    // компонента, в том числе рендеринга dom элементов
    // изменение состояния запустит повторное выполнение кода компонента
    // в том числе и рендеринг
    setValue(level + Math.random() * 200);
  }, [level]);

  return (
    <div>
	    {value}
    </div>
  );
};

export const UseLayoutEffectExample: FC<Props> = ({ level }) => {
  const [value, setValue] = useState(0);

  useLayotEffect(() => {
    // Изменение состояние будет происходить до фазы рендеринга
    // что сделает код более производительным
    setValue(level + Math.random() * 200);
  }, [level]);

  return (
    <div>
	    {value}
    </div>
  );
};

Хук useLayoutEffect позволяет вычислять параметры компонентов и изменять их до того, как браузер отрендерит их. Это предотвращает "мерцание" компонентов и улучшает производительность, потому что рендеринг происходит только 1 раз.

import React, { FC, useRef, useLayotEffect } from "react";

export const ScrollToBottom: FC = () => {
  // useRef используется для получения ссылки на html элемент
  const boxRef = useRef(null);
  
  useLayotEffect(() => {
    // Осуществляем скролл вниз элемента, до того как элемент
    // будет отрендерен, это предотвратит появление элемента со скролом вверху
    // и резким скроллом вниз
    boxRef.current.scrollTo({ top: boxRef.current.scrollHeight });
  }, [])

  return (
    <div ref={boxRef}>
    	... длинный контент
    </div>
  );
};

Когда использовать useEffect без массива зависимостей

Хук useEffect без массива зависимостей будет вызван каждый раз при обновлении компонента. Иногда useEffect используется в связке с useRef, где последний хранит значение переменных, для проброски внутрь useCallback или другого useEffect. Такая связка позволяет использовать предыдущее состояние пропсов и предотвращает обновление колбеков и вызов эффектов. Зачем это нужно поговорим в одной из следующих лекций.

Отсутствие массива зависимостей сделает код более производительным, так как перезапись переменных производительнее проверки массива зависимостей.

import React, { FC, useEffect, useRef } from "react";

export const UseEffectExample: FC = ({ prop1, prop2, prop3 }) => {
  // запись некоторых данных в ref позволяет прокидывать эти данные
  // в колбеки и другие эффекты без их обновления
  const copy1 = useRef();
  useEffect(() => {
    copy1.current = { prop1, prop2, prop3 }
  }, [prop1, prop2, prop3]);
  
  // работает также как и предыдущий пример, но производительнее
  // потому что перезапись переменной оптимальнее проверки массива зависимостей
  const copy2 = useRef();
  useEffect(() => {
    copy2.current = { prop1, prop2, prop3 }
  });

  return (
    <div>
      ...
    </div>
  );
};

Заключение

Разобрали все основные случаи использование useEffect, обсудили в чем разница между useEffect и useLayotEffect и когда использовать каждый из них.

Если статья показалась полезной и интересной, ставьте реакции. Если есть вопросы - пишите в комментариях.

А всех, кто дочитал до конца, хочу пригласить на бесплатный вебинар в рамках которого разберем изменения, которые были добавлены в React 18 и познакомимся с ними поближе.

Зарегистрироваться на урок.

Комментарии (3)


  1. Alexandroppolus
    01.06.2022 15:33
    +2

    useLayoutEffect - срабатывает когда компоненты уже находятся на virtual dom (в памяти и можно прочитать/установить различные свойств), но еще не попали в real dom.

    useLayotEffect(() => {
    boxRef.current.scrollTo({ top: boxRef.current.scrollHeight });
    }, [])

    на момент useLayoutEffect компонент уже попал в реальный dom (и даже записался в ref), но ещё не отрисовался на экране


    1. igor_zvyagin Автор
      01.06.2022 16:40

      Вы правы, благодарю! Исправил


  1. noodles
    03.06.2022 11:30

    Есть ещё такие определения:

    • useEffect - применяет сайдэфекты ПОСЛЕ фазы отрисовки (paint) в браузере.

    • useLayoutEffect - применяет сайдэфекты после расчёта макета (dom calculating / layout / reflow) страницы и ДО фазы отрисовки (paint) в браузере.