В React 19.2 появился новый хук: useEffectEvent. Он кажется идеальным решением проблемы зависимостей, основанных на функциях: просто оберните свою функцию в useEffectEvent, и ваши эффекты больше не будут запускаться только из-за того, что эта функция изменилась. Но все, похоже, натыкаются на это предупреждение в документации:

Only call inside Effects (Вызывайте только внутри эффектов): события эффектов должны вызываться только внутри эффектов. Определяйте их непосредственно перед эффектом, который их использует. Не передавайте их другим компонентам или хукам.

Почему существуют такие строгие правила для простого обертывателя функций?

useEffectEvent появился в RFC три года назад под названием useEvent. Все сочли это хорошей идеей, хотя название вызывало недоумение (я и сам ввязался в ту бессмысленную дискуссию о названиях). В том RFC можно увидеть, как это работает: 

// (!) Примерное поведение 

function useEvent(handler) {
  const handlerRef = useRef(null);

  // В реальной реализации это выполнялось бы до эффектов макета
  useLayoutEffect(() => {
    handlerRef.current = handler;
  });

  return useCallback((...args) => {
    // В реальной реализации это вызывало бы ошибку при вызове во время рендера
    const fn = handlerRef.current;
    return fn(...args);
  }, []);
}

Пример из RFC взят отсюда.

Исходя из этого, похожеuseEffectEvent использует механику эффектов для собственного обновления.

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

Все механизмы, похожие на эффекты — useEffectuseLayoutEffectuseInsertionEffect, а теперь и useEffectEvent — следуют этому потоку (useInsertionEffect немного отличается, так как выполняет очистку непосредственно перед следующим вызовом эффекта, но все равно делает это в порядке снизу вверх).

Если useEffectEvent привязан к конкретному эффекту, то он не будет обновлен, пока не выполнятся эффекты дочерних компонентов.

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

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

Итак, если передавать функцию, обернутую в useEffectEvent, — это плохо, то почему тогда можно передать обычную функцию из родительского компонента и обернуть ее в useEffectEvent для использования уже в текущем компоненте?

Сама функция-свойство обновляется во время фазы рендера, до выполнения любых эффектов. Она указывает на самую актуальную информацию из родительского компонента, доступную текущему компоненту. Поскольку useEffectEvent запускается непосредственно рядом с эффектом, который его использует, у useEffectEvent есть возможность обновить свою ссылку на последнюю версию функции до того, как эффект запустится.

При условии, что useEffectEvent выполняется до useInsertionEffectuseLayoutEffect или useEffect в том же компоненте, гарантированно получаем самую свежую версию функции, выполняемой в эффекте.

А как насчет других утверждений в разделе «Caveats» («Предупреждения» )?

Требование использовать useEffectEvent только с эффектами ограничило бы возможности для неправильного применения.

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

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

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

Надо заметить, что мне не совсем понятно, где именно выполняется useEffectEvent в контексте всех эффектов. Я знаю, что каждый слой эффектов — эффекты вставки, эффекты макета и традиционные эффекты — выполняется как единый целый слой. То есть сначала выполняются все эффекты вставки во всём приложении, затем все эффекты макета, а затем все традиционные эффекты.

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

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

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Michael Landis: useEffectEvent: Why So Strict?

Предыдущая статьяЛинейная регрессия — реализация на Python
Следующая статьяМеханика отображения запросов в Spring Boot