Навигация

useEffect: побочные эффекты, зависимости и cleanup

useEffect — хук для выполнения побочных эффектов в функциональных компонентах React: запросы к API, подписки на события, работа с DOM, таймеры, интеграция со сторонними библиотеками. Эффект выполняется после того, как React закоммитил изменения в DOM. В этой статье разберём механику работы, массив зависимостей, cleanup-функцию, сравнение с Vue и типичные ошибки.

Содержание

  1. Что такое useEffect
  2. Механика работы
  3. Массив зависимостей
  4. Функция очистки
  5. Типичные сценарии использования
  6. Сравнение с useLayoutEffect
  7. Сравнение с Vue-аналогами
  8. Лучшие практики
  9. Распространённые ошибки
  10. Антипаттерны
  11. Шпаргалка: useEffect ↔ Vue
  12. Ключевые тезисы для интервью
  13. Заключение
  14. Полезные ссылки

Что такое useEffect

useEffect — хук для выполнения побочных эффектов (side effects) в функциональных компонентах React. Побочный эффект — это любое взаимодействие с внешним миром: запрос к API, подписка на события DOM, работа с таймерами, прямое изменение DOM-элемента, логирование, аналитика, интеграция со сторонними библиотеками.

Название отражает суть: effect — эффект, который происходит после (use Effect) того, как React закоммитил изменения в DOM.

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();

    return () => {
      connection.disconnect();
    };
  }, [roomId]);

  return <h1>Добро пожаловать в комнату {roomId}!</h1>;
}

Vue-аналог: комбинация хуков жизненного цикла и вотчеров. onMounted + watch наиболее близки. Подробное сравнение — в разделе Сравнение с Vue-аналогами.


Механика работы

Когда срабатывает useEffect

React гарантирует, что эффект выполняется после того, как изменения попали в DOM — то есть после фазы коммита (commit phase). Это ключевое отличие от фазы рендера (render phase), которая должна быть чистой и не содержать побочных эффектов.

Цикл жизни компонента с useEffect:

Монтирование:
  1. Вызов компонента (render) → генерация виртуального DOM
  2. React коммитит изменения в реальный DOM
  3. Запуск useEffect → выполнение побочного эффекта
  
Обновление:
  1. Изменение state/props → новый render
  2. React коммитит изменения в реальный DOM
  3. Запуск функции очистки от предыдущего эффекта (если есть)
  4. Запуск useEffect с новыми зависимостями
  
Размонтирование:
  1. Запуск функции очистки
  2. Удаление компонента из DOM

Очерёдность выполнения

Если на странице несколько компонентов со своими useEffect, их эффекты выполняются в порядке монтирования компонентов в дереве — сверху вниз. Функции очистки, наоборот, вызываются снизу вверх при размонтировании.

function Parent() {
  useEffect(() => {
    console.log("Parent: эффект");
    return () => console.log("Parent: очистка");
  }, []);

  return <Child />;
}

function Child() {
  useEffect(() => {
    console.log("Child: эффект");
    return () => console.log("Child: очистка");
  }, []);

  return <div>Child</div>;
}

// При монтировании:
// "Parent: эффект"
// "Child: эффект"

// При размонтировании:
// "Child: очистка"
// "Parent: очистка"

Строгий режим (StrictMode)

В режиме разработки с <StrictMode> React намеренно дважды вызывает эффект при монтировании (смонтировать → размонтировать → смонтировать), чтобы выявить проблемы с отсутствующей или некорректной очисткой. В production-режиме эффект выполняется один раз.

useEffect(() => {
  // В StrictMode при монтировании этот код выполнится дважды:
  //   Вызов 1: connect() → disconnect() → connect()
  const socket = connect();
  return () => socket.disconnect();
}, []);

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


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

Второй аргумент useEffect — массив зависимостей. React сравнивает каждое значение с предыдущим рендером через Object.is. Если хотя бы одно значение изменилось — эффект перезапускается.

Варианты массива зависимостей

МассивПоведениеАналогия
useEffect(() => { ... })После каждого рендераОпасно в продакшене — почти всегда ошибка
useEffect(() => { ... }, [])Один раз при монтированииcomponentDidMount
useEffect(() => { ... }, [a, b])При изменении a или bcomponentDidUpdate с проверкой
+ return () => { ... }Очистка при размонтированииcomponentWillUnmount

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

Эффект выполняется после каждого рендера — включая самый первый. На практике используется крайне редко и обычно является ошибкой:

function Broken({ count }) {
  useEffect(() => {
    console.log(`Рендер с count = ${count}`);
    // Этот эффект вызывается после КАЖДОГО рендера
  }); // ← нет массива зависимостей
}

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

Гарантирует однократное выполнение при монтировании:

function AppInit() {
  useEffect(() => {
    // Выполнится только один раз
    analytics.track("app_opened");
    setupGlobalListeners();

    return () => {
      // Выполнится только при размонтировании
      teardownGlobalListeners();
    };
  }, []); // ← пустой массив — эффект не зависит ни от чего

  return <Main />;
}

useEffect с конкретными зависимостями

Самый распространённый паттерн. Эффект перезапускается только при изменении указанных значений:

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

  useEffect(() => {
    let cancelled = false;

    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        if (!cancelled) setUser(data);
      });

    return () => {
      cancelled = true;
    };
  }, [userId]); // ← перезапуск только при смене userId

  if (!user) return <Skeleton />;
  return <div>{user.name}</div>;
}

Примитивы vs объекты/массивы в зависимостях

Object.is — поверхностное сравнение. Два объекта с одинаковым содержимым, но разными ссылками — разные зависимости:

function Bad({ options }) {
  useEffect(() => {
    // ❌ options — новый объект на каждом рендере родителя
    applyOptions(options);
  }, [options]); // Эффект перезапускается каждый рендер!
}

// ✅ Исправление: передавайте примитивы или стабильные ссылки
function Good({ fontSize, lineHeight }) {
  useEffect(() => {
    applyOptions({ fontSize, lineHeight });
  }, [fontSize, lineHeight]); // Перезапуск только при изменении примитивов
}

Честные зависимости

Любая переменная из замыкания, используемая внутри эффекта, обязана быть в массиве зависимостей. ESLint-плагин eslint-plugin-react-hooks с правилом react-hooks/exhaustive-deps автоматически проверяет это.

Ложь о зависимостях приводит к stale closure — эффект «видит» устаревшие значения из прошлого рендера:

function StaleClosure({ step }) {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      // ❌ count всегда равен 0 — значение из первого рендера!
      setCount(count + step);
    }, 1000);

    return () => clearInterval(id);
  }, []); // ESLint предупредит: missing deps: count, step

  return <div>{count}</div>;
}

// ✅ Исправление через функциональный setState
function Fixed({ step }) {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + step); // Читаем актуальное значение
    }, 1000);

    return () => clearInterval(id);
  }, [step]); // Честная зависимость

  return <div>{count}</div>;
}

Функция очистки

Функция, возвращаемая из эффекта — это cleanup-функция. React вызывает её в двух случаях:

  1. Перед повторным запуском эффекта (изменились зависимости).
  2. При размонтировании компонента.
useEffect(() => {
  // Установка
  const controller = new AbortController();
  const { signal } = controller;

  fetch(url, { signal })
    .then(res => res.json())
    .then(setData)
    .catch(err => {
      if (err.name !== "AbortError") setError(err);
    });

  // Очистка
  return () => {
    controller.abort();
  };
}, [url]);

Что обязательно чистить

РесурсСозданиеОчистка
setInterval / setTimeoutconst id = setInterval(fn, ms)clearInterval(id)
addEventListenerwindow.addEventListener("resize", fn)window.removeEventListener("resize", fn)
WebSocket / SSEconst ws = new WebSocket(url)ws.close()
IntersectionObserverconst io = new IntersectionObserver(fn)io.disconnect()
fetch / AbortControllerconst ctrl = new AbortController()ctrl.abort()
Подписки (EventEmitter)emitter.on("event", fn)emitter.off("event", fn)
Прямые DOM-мутацииel.style.color = "red"el.style.color = "" (восстановить)

Очистка не нужна

Некоторым эффектам не нужна очистка — например, отправка аналитики, логирование или однократная настройка глобального состояния:

useEffect(() => {
  analytics.track("page_view", { page: pathname });
}, [pathname]);
// Нет возврата cleanup-функции — это нормально

Типичные сценарии использования

1. Подписка на внешний источник данных

function StockTicker({ symbol }) {
  const [price, setPrice] = useState(null);

  useEffect(() => {
    const ws = new WebSocket(`wss://stocks.example.com/${symbol}`);

    ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      setPrice(data.price);
    };

    ws.onerror = (err) => {
      console.error("WebSocket error", err);
    };

    return () => {
      ws.close();
    };
  }, [symbol]);

  return <div>{symbol}: ${price ?? "..."}</div>;
}

2. Синхронизация с DOM API (классы, атрибуты, фокус)

function Modal({ isOpen, children }) {
  const dialogRef = useRef(null);

  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;

    if (isOpen) {
      dialog.showModal();
      document.body.style.overflow = "hidden";
    } else {
      dialog.close();
      document.body.style.overflow = "";
    }

    return () => {
      document.body.style.overflow = "";
    };
  }, [isOpen]);

  return <dialog ref={dialogRef}>{children}</dialog>;
}

3. Загрузка данных (fetch)

function ProductPage({ productId }) {
  const [product, setProduct] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    const controller = new AbortController();

    setLoading(true);
    setError(null);

    fetch(`/api/products/${productId}`, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(data => {
        if (!cancelled) {
          setProduct(data);
          setLoading(false);
        }
      })
      .catch(err => {
        if (!cancelled && err.name !== "AbortError") {
          setError(err);
          setLoading(false);
        }
      });

    return () => {
      cancelled = true;
      controller.abort();
    };
  }, [productId]);

  if (loading) return <Skeleton />;
  if (error) return <ErrorBanner error={error} />;
  return <ProductDetail product={product} />;
}

Примечание: В 2026 году для загрузки данных рекомендуется использовать TanStack Query (ранее React Query) или хук use() из React 19 + <Suspense>, а не useEffect + useState. Подробнее: TanStack Query и React use().

4. Интеграция со сторонними библиотеками

function Chart({ data, width, height }) {
  const containerRef = useRef(null);
  const chartRef = useRef(null);

  useEffect(() => {
    if (!containerRef.current) return;

    // Инициализация D3 / Chart.js / Leaflet
    const chart = new ThirdPartyChart(containerRef.current, {
      width,
      height,
    });

    chart.render(data);
    chartRef.current = chart;

    return () => {
      chart.destroy();
      chartRef.current = null;
    };
  }, [data, width, height]);

  return <div ref={containerRef} />;
}

5. Обработка глобальных событий (клавиатура, resize, scroll)

function useKeyPress(targetKey) {
  const [isPressed, setIsPressed] = useState(false);

  useEffect(() => {
    const handleDown = (e) => {
      if (e.key === targetKey) setIsPressed(true);
    };
    const handleUp = (e) => {
      if (e.key === targetKey) setIsPressed(false);
    };

    window.addEventListener("keydown", handleDown);
    window.addEventListener("keyup", handleUp);

    return () => {
      window.removeEventListener("keydown", handleDown);
      window.removeEventListener("keyup", handleUp);
    };
  }, [targetKey]);

  return isPressed;
}

6. Установка и очистка интервалов

function Countdown({ from }) {
  const [count, setCount] = useState(from);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => {
        if (c <= 1) {
          clearInterval(id);
          return 0;
        }
        return c - 1;
      });
    }, 1000);

    return () => clearInterval(id);
  }, [from]);

  return <div>{count}</div>;
}

Сравнение с useLayoutEffect

useLayoutEffect — менее известный «брат» useEffect. Разница — в моменте выполнения:

useEffectuseLayoutEffect
Момент выполненияПосле отрисовки в браузере (асинхронно)До отрисовки в браузере (синхронно, блокирует)
Пользователь видитПромежуточное состояние → эффект → перерисовкаСразу финальное состояние
Для чегоПодавляющее большинство эффектовИзмерения DOM, синхронные мутации DOM
ПроизводительностьНе блокирует отрисовкуБлокирует — используйте редко
function Tooltip({ x, y, content }) {
  const tooltipRef = useRef(null);
  const [adjustedY, setAdjustedY] = useState(y);

  // ❌ useEffect: пользователь увидит моргание — сначала старый y, потом скорректированный
  // ✅ useLayoutEffect: коррекция ДО отрисовки, моргания нет
  useLayoutEffect(() => {
    if (!tooltipRef.current) return;
    const { height } = tooltipRef.current.getBoundingClientRect();
    if (y + height > window.innerHeight) {
      setAdjustedY(window.innerHeight - height - 10);
    } else {
      setAdjustedY(y);
    }
  }, [y]);

  return (
    <div ref={tooltipRef} style={{ position: "fixed", left: x, top: adjustedY }}>
      {content}
    </div>
  );
}

Правило: всегда начинайте с useEffect. Если видите визуальное моргание или неправильные замеры — переходите на useLayoutEffect.

Vue-аналог: useEffect ≈ watch / watchEffect с nextTick (после обновления DOM). useLayoutEffect ≈ watch с flush: "sync" или onMounted / onUpdated — вызываются синхронно после мутации DOM, но до отрисовки браузером.


Сравнение с Vue-аналогами

В Vue нет единого хука, идентичного useEffect. Функциональность useEffect распределена между несколькими API Composition API:

1. onMounted + onUnmounted — аналог useEffect(fn, [])

// React: однократное выполнение с очисткой
useEffect(() => {
  const ws = connect(url);
  return () => ws.close();
}, []);

// Vue: отдельные хуки жизненного цикла
import { onMounted, onUnmounted } from "vue";

let ws;
onMounted(() => {
  ws = connect(url);
});
onUnmounted(() => {
  ws?.close();
});

2. watchEffect — аналог useEffect с авто-отслеживанием зависимостей

// React: явный массив зависимостей
useEffect(() => {
  console.log(`Count изменился: ${count}`);
}, [count]);

// Vue: автоматическое отслеживание
watchEffect(() => {
  console.log(`Count изменился: ${count.value}`);
  // Vue сам понимает, что эффект зависит от count
});

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

3. watch — аналог useEffect с явными зависимостями

// React
useEffect(() => {
  fetchUser(userId, role);
}, [userId, role]);

// Vue
watch(
  [() => userId.value, () => role.value],
  ([newUserId, newRole]) => {
    fetchUser(newUserId, newRole);
  }
);

4. Cleanup-функция

// React: возврат cleanup-функции
useEffect(() => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id);
}, []);

// Vue: onCleanup внутри watchEffect / watch
watchEffect((onCleanup) => {
  const id = setInterval(tick, 1000);
  onCleanup(() => clearInterval(id));
});

5. onUpdated — аналог useEffect без зависимостей

// React: эффект после каждого рендера
useEffect(() => {
  console.log("Компонент обновился");
});

// Vue
onUpdated(() => {
  console.log("Компонент обновился");
});

Сводная таблица соответствий

ReactVue (Composition API)
useEffect(fn, []) — монтированиеonMounted(fn)
return () => cleanup() внутри useEffectonUnmounted(fn)
useEffect(fn, [a, b]) — по зависимостямwatch([a, b], fn)
useEffect(fn) — после каждого рендераonUpdated(fn)
useEffect(fn) с авто-отслеживаниемwatchEffect(fn)
Cleanup-функцияonCleanup(fn) — переданная в watch/watchEffect
useLayoutEffect(fn, deps)watch(fn, { flush: "sync" })

Ключевое архитектурное различие: React требует явного массива зависимостей для useEffect. Vue, благодаря системе реактивности на Proxy, автоматически собирает зависимости внутри watchEffect, что исключает ошибки пропущенных или лишних зависимостей. С другой стороны, явный массив в React даёт более предсказуемое поведение и упрощает дебаггинг — вы всегда видите, от чего зависит эффект.


Лучшие практики

1. Каждый эффект — одна ответственность

Не смешивайте несвязанную логику в одном useEffect. Лучше несколько узконаправленных эффектов:

// ❌ Смешанная логика
useEffect(() => {
  analytics.track("page_view");
  document.title = `${user.name} — Dashboard`;
  const id = setInterval(pollNotifications, 5000);
  return () => clearInterval(id);
}, [user]);

// ✅ Раздельные эффекты
useEffect(() => {
  analytics.track("page_view", { page: pathname });
}, [pathname]);

useEffect(() => {
  document.title = `${user.name} — Dashboard`;
}, [user.name]);

useEffect(() => {
  const id = setInterval(pollNotifications, 5000);
  return () => clearInterval(id);
}, []);

2. Выносите логику в пользовательские хуки

function useDocumentTitle(title) {
  useEffect(() => {
    const prev = document.title;
    document.title = title;
    return () => {
      document.title = prev;
    };
  }, [title]);
}

function useInterval(callback, delay) {
  const savedCallback = useRef(callback);

  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  useEffect(() => {
    if (delay === null) return;
    const id = setInterval(() => savedCallback.current(), delay);
    return () => clearInterval(id);
  }, [delay]);
}

3. Всегда предусматривайте отмену асинхронных операций

useEffect(() => {
  let cancelled = false;

  fetchData(id).then(data => {
    if (!cancelled) setData(data);
  });

  return () => {
    cancelled = true;
  };
}, [id]);

4. Используйте функциональный setState внутри эффектов

Когда новое состояние зависит от предыдущего — чтобы избежать count в зависимостях:

// ❌ count в зависимостях — эффект перезапускается при каждом изменении
useEffect(() => {
  const id = setInterval(() => setCount(count + 1), 1000);
  return () => clearInterval(id);
}, [count]);

// ✅ Функциональный setState — count не нужен в зависимостях
useEffect(() => {
  const id = setInterval(() => setCount(c => c + 1), 1000);
  return () => clearInterval(id);
}, []);

5. Предпочитайте use() + <Suspense> для загрузки данных

В React 19 классический паттерн useEffect + useState для загрузки данных устарел. Современная альтернатива:

// ❌ Устаревший подход (React 16–18)
function Profile({ userId }) {
  const [user, setUser] = useState(null);
  useEffect(() => {
    fetch(`/api/user/${userId}`).then(r => r.json()).then(setUser);
  }, [userId]);
  if (!user) return <Loading />;
  return <div>{user.name}</div>;
}

// ✅ Современный подход (React 19+)
function Profile({ userPromise }) {
  const user = use(userPromise);
  return <div>{user.name}</div>;
}

Подробнее — React use() — Полное руководство.

6. Не бойтесь нескольких useEffect

Больше эффектов = читаемее код. React группирует обновления от нескольких setState внутри одного эффекта в один батч, так что дополнительные рендеры не возникают.


Распространённые ошибки

1. Бесконечный цикл ререндеров

Эффект обновляет состояние, которое указано в его зависимостях — и так по кругу:

// ❌ Бесконечный цикл
useEffect(() => {
  setData(transform(data));  // data изменяется → эффект запускается → data изменяется → ...
}, [data]);

// ✅ Решение: вычислять transform в рендере, а не в эффекте
const transformedData = useMemo(() => transform(rawData), [rawData]);

2. Stale closure (устаревшее замыкание)

function Bad({ userId }) {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      console.log(`User ${userId}, count ${count}`);
      // userId и count — значения на момент создания эффекта!
    }, 1000);
    return () => clearInterval(id);
  }, []); // ← пустой массив, хотя userId и count используются внутри
}

3. Пропуск функции очистки

// ❌ Утечка: интервал продолжает тикать после размонтирования
useEffect(() => {
  setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
}, []);

// ✅ Очистка
useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  return () => clearInterval(id);
}, []);

4. Объект как зависимость

function Bad() {
  const [options, setOptions] = useState({ page: 1, size: 10 });

  useEffect(() => {
    fetchPage(options.page, options.size);
  }, [options]); // ❌ options — новый объект при каждом setOptions
}

// ✅ Примитивы
function Good() {
  const [page, setPage] = useState(1);
  const [size, setSize] = useState(10);

  useEffect(() => {
    fetchPage(page, size);
  }, [page, size]); // ✅ Сравнение Object.is примитивов
}

5. useEffect там, где эффект не нужен

Не всякая логика, вызываемая «при изменении», должна идти в useEffect:

// ❌ Избыточный эффект — фильтрацию можно сделать прямо в рендере
function Bad({ items, query }) {
  const [filtered, setFiltered] = useState([]);

  useEffect(() => {
    setFiltered(items.filter(i => i.name.includes(query)));
  }, [items, query]);

  return <List items={filtered} />;
}

// ✅ Производные данные вычисляются в рендере (или через useMemo для оптимизации)
function Good({ items, query }) {
  const filtered = useMemo(
    () => items.filter(i => i.name.includes(query)),
    [items, query]
  );

  return <List items={filtered} />;
}

6. Асинхронная функция как колбэк useEffect

// ❌ Не работает как ожидается — async-функция возвращает Promise, не cleanup
useEffect(async () => {
  const data = await fetchData();
  setData(data);
}, []);

// ✅ Правильно: определить async-функцию внутри, вызвать её
useEffect(() => {
  async function load() {
    const data = await fetchData();
    setData(data);
  }
  load();
}, []);

Антипаттерны

❌ Огромный эффект со всей логикой компонента

// ❌ 50+ строк в одном эффекте
useEffect(() => {
  // Загрузка данных
  // Подписка на события
  // Аналитика
  // Таймеры
  // Обновление document.title
  // ...
}, [dep1, dep2, dep3, dep4, dep5]);

❌ Подавление ESLint-правила exhaustive-deps

// ❌ Отключение предупреждения — верный путь к stale closure
useEffect(() => {
  doSomething(count, userId);
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

❌ Использование useEffect только для запуска setState

// ❌ Эффект, который просто синхронизирует пропс в состояние
function Bad({ initialValue }) {
  const [value, setValue] = useState(initialValue);

  useEffect(() => {
    setValue(initialValue);
  }, [initialValue]);
}

// ✅ Либо используйте пропс напрямую, либо key для сброса
function Good({ initialValue }) {
  const [value, setValue] = useState(initialValue);
  // Если initialValue меняется — используйте key на родителе для ремаунта
  return <input value={value} onChange={e => setValue(e.target.value)} />;
}

// Альтернативный родитель:
<ControlledInput key={userId} initialValue={fetchDefault(userId)} />

❌ Создание Promise прямо в эффекте без отмены

// ❌ Race condition: если userId меняется быстро, старый запрос может перезаписать новый
useEffect(() => {
  fetchUser(userId).then(setUser);
}, [userId]);

// ✅ AbortController или флаг cancelled
useEffect(() => {
  const controller = new AbortController();
  fetchUser(userId, { signal: controller.signal })
    .then(setUser)
    .catch(err => {
      if (err.name !== "AbortError") throw err;
    });
  return () => controller.abort();
}, [userId]);

Шпаргалка: useEffect ↔ Vue

СитуацияReact (useEffect)Vue (Composition API)
Выполнить один раз при монтированииuseEffect(fn, [])onMounted(fn)
Очистка при размонтированииreturn () => cleanup()onUnmounted(cleanup)
Реакция на изменение a и buseEffect(fn, [a, b])watch([a, b], fn)
Автоматическое отслеживание зависимостейНет аналога (useEffectEvent — другое)watchEffect(fn)
Выполнить перед каждым рендеромuseLayoutEffect(fn, deps)watch(fn, { flush: "sync" })
Очистка внутри watchreturn () => cleanup()onCleanup(() => cleanup())
Несколько эффектовНесколько useEffect (рекомендуется)Несколько watch / onMounted
Отказ от эффекта для загрузки данныхuse(promise) + <Suspense>await + <Suspense>
Отмена fetch при размонтированииAbortController в cleanupAbortController в onCleanup
StrictMode (двойной вызов в dev)Да, встроен в React 18+Нет аналога

Главный вывод: React-разработчику, переходящему на Vue, нужно привыкнуть к автоматическому отслеживанию зависимостей в watchEffect и разделению логики монтирования/размонтирования на отдельные хуки onMounted/onUnmounted. Vue-разработчику, переходящему на React, — к явному массиву зависимостей и единому useEffect вместо разрозненных хуков жизненного цикла. Обе модели эквивалентны по возможностям, разница — в эргономике и подверженности ошибкам: React легче дебажить (видно все зависимости), Vue сложнее забыть зависимость (она отслеживается автоматически).


Ключевые тезисы для интервью

  • useEffect выполняется после commit phase — изменения уже в DOM.
  • Массив зависимостей определяет, когда эффект перезапускается; Object.is для сравнения.
  • Честные зависимости обязательны — иначе stale closure (устаревшее замыкание).
  • Cleanup-функция чистит подписки, таймеры, AbortController перед повторным запуском и при размонтировании.
  • useLayoutEffect — синхронно до paint, для измерений DOM; useEffect — асинхронно после paint.
  • StrictMode вызывает эффект дважды в dev для обнаружения проблем с очисткой.
  • Каждый эффект — одна ответственность; не смешивайте несвязанную логику.
  • Производные данные вычисляйте в рендере (или через useMemo), а не в useEffect.
  • В React 19 для загрузки данных предпочтительнее use() + <Suspense>.

Заключение

useEffect — основной хук для побочных эффектов в React. Ключевые правила: честные зависимости, обязательная очистка подписок и таймеров, один эффект — одна ответственность. Всегда начинайте с useEffect; переходите на useLayoutEffect только при визуальном моргании. Для загрузки данных в React 19 предпочтительнее use() + <Suspense>, а не useEffect + useState.

Полезные ссылки