Урок 15: Зависимости useEffect в React

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

Почему эта тема так нужна? Потому что неправильное использование зависимостей, это одна из самых частых причин багов в React-приложениях. Эффекты могут выполняться слишком часто, «тормозить» приложение или, наоборот, не срабатывать тогда, когда должны. После этого урока вы будете чётко понимать, как и когда ваш эффект «просыпается» и начинает работу.

Давайте без лишних предисловий погрузимся в суть.

Зачем нужен массив зависимостей?

Массив зависимостей это второй аргумент, который мы передаем в хук useEffect. Именно он дирижирует оркестром нашего эффекта, говоря ему, когда именно нужно исполнить свою партию.

Представьте, что ваш эффект это сторожевой пёс. А массив зависимостей это список «запахов», на которые он должен реагировать. Если вы не дадите ему никаких запахов (пустой массив []), он будет спать всё время, кроме момента первой установки компонента. Если вы дадите ему список запахов (например, [запах1, запах2]), он будет просыпаться и лаять только тогда, когда любой из этих запахов изменится. А если вы вообще не дадите ему инструкций (отсутствие массива), он будет бдеть постоянно и лаять на каждое малейшее движение в доме (каждый рендер).

Основная философия, что массив зависимостей это способ синхронизации вашего эффекта с данными (пропсами или состоянием) извне. React сравнивает текущие значения в массиве с теми, что были на предыдущем рендере. Если хотя бы одно значение изменилось (согласно Object.is), эффект выполняется заново.

Давайте теперь разберём каждый сценарий детально.

useEffect без массива зависимостей

Начнём с самого «бурного» сценария.

jsx
import React, { useState, useEffect } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // Этот эффект выполняется после КАЖДОГО рендера компонента
    console.log('Эффект выполнен! Текущий счёт:', count);

    // Обновляем заголовок документа после каждого рендера
    document.title = `Вы нажали ${count} раз`;
  }); // Обратите внимание: здесь нет второго аргумента!

  return (
    <div>
      <p>Вы нажали {count} раз</p>
      <button onClick={() => setCount(count + 1)}>
        Нажми меня
      </button>
    </div>
  );
}

Что здесь происходит?

  1. Компонент рендерится впервые. React выполняет эффект. В консоли видим: "Эффект выполнен! Текущий счёт: 0", а заголовок страницы меняется.

  2. Вы нажимаете на кнопку. Состояние count обновляется.

  3. Компонент Counter перерендеривается с новым значением count.

  4. Поскольку массив зависимостей отсутствует, React не проверяет никакие зависимости и выполняет эффект снова. В консоли: "Эффект выполнен! Текущий счёт: 1", заголовок снова обновляется.

  5. Этот цикл повторяется при каждом обновлении компонента (из-за изменения состояния, получения новых пропсов или перерендера родителя).

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

Этот вариант нужен крайне редко. Например, если ваш эффект действительно должен «подстраиваться» под каждый рендер и не имеет никаких данных, от которых он зависит. Но на практике почти у каждого эффекта есть зависимости. Использование useEffect без массива может легко привести к бесконечным циклам и проблемам с производительностью, если эффект тяжелый (например, делает сетевой запрос).

Практическая задача 1 (Разминка):
Создайте компонент, который выводит в консоль сообщение «Компонент обновился!» после каждого своего рендера, используя useEffect без массива зависимостей. Добавьте кнопку, которая меняет какое-нибудь состояние, чтобы увидеть, как эффект срабатывает при каждом клике.

useEffect с пустым массивом зависимостей []

Это полная противоположность предыдущему варианту. Эффект становится «одиночным» событием в жизни компонента.

jsx
import React, { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    // Этот эффект выполняется только один раз: после ПЕРВОГО рендера (монтирования)
    console.log('Компонент примонтировался! Загружаем данные пользователя...');

    // Эмуляция запроса к API
    fetch(`/api/users/${userId}`)
      .then(response => response.json())
      .then(userData => setUser(userData));
  }, []); // Пустой массив зависимостей

  return (
    <div>
      {user ? <h1>Привет, {user.name}!</h1> : <p>Загрузка...</p>}
    </div>
  );
}

Что здесь происходит?

  1. Компонент монтируется в DOM.

  2. React выполняет эффект. Данные пользователя начинают загружаться.

  3. Если проп userId изменится, эффект не выполнится снова, потому что массив зависимостей пуст. React видит, что зависимости (отсутствуют) не изменились с момента прошлого рендера и пропускает выполнение эффекта. Это баг! Мы поговорим, как его исправить, в следующем разделе.

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

Пустой массив [] идеален для эффектов, которые должны запускаться только один раз, при «рождении» компонента. Классические примеры:

  • Начальная загрузка данных (хотя часто и это требует зависимостей).

  • Подписка на глобальные события (например, window.addEventListener('resize', ...)).

  • Установка таймеров (setInterval), которые не должны перезапускаться.

Важное предупреждение о ESLint:

Если вы используете Create React App или подобные настройки, встроенный линтер (ESLint) для React Hooks обязательно «ругнётся» на пример выше. Он увидит, что внутри эффекта используется проп userId и предложит вам добавить его в массив зависимостей. И он будет прав! Пока просто запомните это правило: все значения из области видимости компонента (состояния, пропсы, контекст), которые используются внутри эффекта, должны быть перечислены в его массиве зависимостей. Игнорирование этого правила, является источником многих ошибок.

Практическая задача 2 (Начальная настройка):
Создайте компонент «Таймер обратного отсчета», который стартует с 10 и уменьшается на 1 каждую секунду. Используйте setInterval внутри useEffect с пустым массивом зависимостей, чтобы запустить таймер только при монтировании. Не забудьте об очистке с помощью clearInterval в функции, возвращаемой из эффекта!

useEffect с массивом зависимостей

А вот и самый распространённый и полезный случай. Здесь мы явно указываем эффекту: «Следи за этими значениями и перезапускайся только тогда, когда любое из них изменится».

Давайте исправим наш предыдущий пример с загрузкой пользователя.

jsx
import React, { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    // Этот эффект выполняется после первого рендера и после любого рендера, в котором изменился `userId`
    console.log(`Запрашиваем данные для пользователя с ID: ${userId}`);

    fetch(`/api/users/${userId}`)
      .then(response => response.json())
      .then(userData => setUser(userData));

    // Опционально: можно сбросить user на null при запросе новых данных
    setUser(null);
  }, [userId]); // Массив с одной зависимостью: userId

  return (
    <div>
      {user ? <h1>Привет, {user.name}!</h1> : <p>Загрузка профиля #{userId}...</p>}
    </div>
  );
}

Что здесь происходит?

  1. При первом рендере с userId=1 эффект выполняется и загружает пользователя с ID 1.

  2. Если родительский компонент передаст новый проп, например, userId=2, компонент перерендерится.

  3. React берет массив зависимостей [userId] и сравнивает: «Предыдущий userId был 1, новый 2. Значения разные!».

  4. Эффект выполняется заново, загружая данные для пользователя с ID 2.

  5. Если компонент перерендерится по другой причине (например, из-за изменения другого состояния), но userId останется прежним, эффект выполняться не будет.

Это и есть синхронизация. Мы синхронизировали наш эффект (загрузку данных) с пропсом userId.

Что можно и нужно класть в массив зависимостей?

  • Пропсы (как в примере выше).

  • Состояния (значения, созданные с помощью useState).

  • Значения из контекста (полученные через useContext).

  • Другие значения из тела компонента, которые могут меняться (например, производные вычисления от состояний и пропсов).

Практическая задача 3 (поиск по запросу):
Создайте компонент поиска. Поле ввода (input), значение которого хранится в состоянии query. Используйте useEffect с массивом зависимостей [query]. Внутри эффекта, при изменении query, должен выполняться «поиск» (например, выводить в консоль "Ищем: ${query}"). Используйте setTimeout с задержкой в 500 мс, чтобы не делать запрос на каждое нажатие клавиши (это называется «дебаунс»). Не забудьте очищать таймер с помощью clearTimeout в функции очистки эффекта!

Типичные ошибки и подводные камни

Давайте теперь поговорим о том, где чаще всего спотыкаются новички (да и опытные разработчики тоже).

1. Ложные срабатывания из-за объектов и массивов

Помните, React сравнивает зависимости с помощью Object.is. Для примитивов (числа, строки, булевы) это работает ожидаемо. Но для объектов, массивов и функций нет.

jsx
function MyComponent() {
  const [data, setData] = useState({ name: 'Max' });

  useEffect(() => {
    console.log('Эффект сработал!');
    // Что-то делаем с data...
  }, [data]); // Зависимость - объект

  const updateData = () => {
    // Устанавливаем тот же самый объект? Нет, это НОВЫЙ объект!
    setData({ name: 'Max' });
  };

  return <button onClick={updateData}>Обновить</button>;
}

При каждом клике на кнопку мы создаем новый объект { name: 'Max' }. Хотя его содержимое идентично предыдущему, для React это два разных объекта. Сравнение Object.is({ name: 'Max' }, { name: 'Max' }) вернет false. Поэтому эффект будет выполняться при каждом клике, даже если фактические данные не изменились.

Решение: Используйте хук useMemo для мемоизации объектов и массивов и useCallback для функций, если вам нужно передавать их в массивы зависимостей.

jsx
const data = useMemo(() => ({ name: 'Max' }), []);
const updateData = useCallback(() => { /* ... */ }, []);

2. Бесконечные циклы

Самая страшная и частая ошибка.

jsx
// ПЛОХОЙ ПРИМЕР! НЕ ДЕЛАЙТЕ ТАК!
const [count, setCount] = useState(0);

useEffect(() => {
  // Эффект запускается...
  setCount(count + 1); // ...обновляет состояние `count`...
  // ...что вызывает перерендер...
  // ...после которого эффект видит, что `count` изменился и запускается снова!
}, [count]); // Зависимость - count, который мы меняем внутри

Это создаст бесконечный цикл рендеров и, скорее всего, «уронит» ваше приложение.

Решение: Всегда спрашивайте себя: «Действительно ли мне нужно запускать этот эффект при изменении этой зависимости?». Иногда состояние можно вычислить на основе предыдущего значения, используя функциональную форму обновления состояния, чтобы убрать ненужную зависимость.

jsx
// ХОРОШО
useEffect(() => {
  const intervalId = setInterval(() => {
    setCount(prevCount => prevCount + 1); // Используем предыдущее состояние
  }, 1000);
  return () => clearInterval(intervalId);
}, []); // Теперь нам не нужно добавлять `count` в зависимости!

3. «Забытые» зависимости и предупреждения линтера

Никогда, слышите, никогда не игнорируйте предупреждения линтера react-hooks/exhaustive-deps! Если он говорит вам добавить зависимость, сделайте это. Если вы уверены, что зависимость не нужна (крайне редкий случай), вы можете явно проигнорировать правило, но это должно быть обоснованным решением.

jsx
// Линтер скажет: "Hey, 'fetchData' или 'currentPage' missing in dependencies!"
useEffect(() => {
  fetchData(currentPage);
}, []); // Линтер предупредит!

// Правильно:
useEffect(() => {
  fetchData(currentPage);
}, [fetchData, currentPage]); // Все зависимости на месте

Практикуемся, делаем свой «умный» счётчик

Давайте закрепим всё на более сложном примере. Создадим счётчик, который:

  1. Увеличивается сам каждую секунду.

  2. Позволяет увеличивать себя вручную кнопкой.

  3. Имеет кнопку «Ускорить», которая удваивает скорость авто-инкремента.

  4. Логирует текущее значение в консоль только при ручном нажатии.

jsx
import React, { useState, useEffect, useCallback } from 'react';

function SmartCounter() {
  const [count, setCount] = useState(0);
  const [delay, setDelay] = useState(1000); // Начальная задержка: 1000 мс

  // Эффект для авто-инкремента. Зависит от `delay`.
  // Если `delay` меняется, старый интервал сбрасывается и создается новый.
  useEffect(() => {
    console.log(`Запускаем интервал с задержкой ${delay}мс`);
    const intervalId = setInterval(() => {
      setCount(prevCount => prevCount + 1); // Используем функциональное обновление
    }, delay);

    // Функция очистки: сбрасывает интервал при размонтировании или изменении `delay`
    return () => {
      console.log('Сбрасываем интервал');
      clearInterval(intervalId);
    };
  }, [delay]); // Зависимость - `delay`

  // Эффект для логирования при ручном нажатии. НЕ должен срабатывать при авто-инкременте.
  const [lastManualIncrement, setLastManualIncrement] = useState(0);
  useEffect(() => {
    if (lastManualIncrement > 0) { // Чтобы не логировать при монтировании
      console.log(`Пользователь вручную увеличил счётчик до: ${count}`);
    }
  }, [lastManualIncrement]); // Зависим только от значения, которое меняется при ручном клике

  // Обработчик ручного увеличения
  const handleManualIncrement = useCallback(() => {
    setCount(prevCount => prevCount + 1);
    setLastManualIncrement(Date.now()); // Устанавливаем метку времени
  }, []);

  // Обработчик ускорения
  const handleSpeedUp = useCallback(() => {
    setDelay(prevDelay => prevDelay / 2); // Уменьшаем задержку в 2 раза
  }, []);

  return (
    <div>
      <h1>Счётчик: {count}</h1>
      <button onClick={handleManualIncrement}>Увеличить вручную</button>
      <button onClick={handleSpeedUp}>Ускорить (x2)</button>
      <p>Текущая задержка: {delay} мс</p>
    </div>
  );
}

export default SmartCounter;

Разбор:

  • У нас два эффекта. Первый управляет интервалом и зависит от delay. Второй отвечает за логирование и зависит от lastManualIncrement.

  • Мы используем useCallback для стабилизации функций-обработчиков. Это хорошая практика, особенно если бы мы передавали их в массивы зависимостей дочерних компонентов.

  • Функция очистки в первом эффекте гарантирует, что при изменении delay или размонтировании старый интервал будет корректно удалён, что предотвращает утечки памяти.

Итоги и ключевые выводы

Поздравляю! Вы только что прошли один из самых важных уроков в освоении React. Давайте резюмируем:

  1. Массив зависимостей это инструкция для синхронизации. Он говорит эффекту, с какими данными он должен «идти в ногу».

  2. Три варианта:

    •  — Нет массива. Эффект выполняется после каждого рендера. Используется крайне редко.

    •  — Пустой массив []. Эффект выполняется один раз, при монтировании. Идеально для инициализации.

    •  — Массив с зависимостями [a, b, c]. Эффект выполняется при первом рендере и при изменении любой из зависимостей a, b или c. Самый частый случай.

  3. Следуйте правилам линтера. Всегда добавляйте в массив все значения из области видимости компонента, которые используются в эффекте.

  4. Помните о сравнении зависимостей. Объекты, массивы и функции, созданные внутри рендера, будут каждый раз новыми, что может вызывать лишние срабатывания эффектов. Используйте useMemo и useCallback для стабилизации.

  5. Всегда думайте об очистке. Если ваш эффект что-то подписывает, устанавливает таймер и т.д., возвращайте функцию очистки.

Этот материал требует практики. Не расстраивайтесь, если с первого раза всё не идеально. Возвращайтесь к этому уроку, перечитывайте, экспериментируйте с кодом в песочнице (например, CodeSandbox). Понимание работы useEffect и его зависимостей это 80% успеха в создании стабильных и предсказуемых React-приложений.

Чтобы стать мастером React, нужно понять его философию синхронизации данных с UI. А useEffect это ваш главный инструмент для этого.

Хочешь пройти весь путь от начинающего до уверенного React-разработчика? Изучи полный курс с уроками по React для начинающих на моём сайте! Там тебя ждут все 30 уроков, практические проекты и многое другое.

Поделиться статьей:
Поддержать автора блога

Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.

Персональные рекомендации
Оставить комментарий