Отделение событий от эффектов
Обработчики событий запускаются повторно только тогда, когда вы снова выполняете то же взаимодействие. В отличие от обработчиков событий, эффекты синхронизируются повторно, если какое-либо значение, которое они считывают, например свойство или переменная состояния, отличается от значения при предыдущем рендеринге. Иногда вам нужно сочетание обоих вариантов поведения: эффект, который запускается повторно в ответ на одни значения, но не на другие. На этой странице вы узнаете, как этого добиться.
Вы узнаете
- Как выбрать между обработчиком события и эффектом
- Почему эффекты реактивны, а обработчики событий — нет
- Что делать, если вы хотите, чтобы часть кода эффекта не была реактивной
- Что такое события эффектов и как извлекать их из эффектов
- Как считывать последние значения свойств и состояния в эффектах с помощью событий эффектов
Выбор между обработчиками событий и эффектами
Для начала вспомним, чем отличаются обработчики событий от эффектов.
Представьте, что вы реализуете компонент комнаты чата. Требования выглядят так:
- Компонент должен автоматически подключаться к выбранной комнате чата.
- При нажатии кнопки «Отправить» он должен отправлять сообщение в чат.
Предположим, вы уже написали для этого код, но не уверены, куда его поместить. Использовать обработчики событий или эффекты? Каждый раз, когда вам нужно ответить на этот вопрос, подумайте, зачем должен выполняться этот код.
Обработчики событий запускаются в ответ на конкретные взаимодействия
С точки зрения пользователя, сообщение должно отправляться потому, что была нажата именно кнопка «Отправить». Пользователь будет очень недоволен, если вы отправите его сообщение в другое время или по другой причине. Именно поэтому отправка сообщения должна быть обработчиком события. Обработчики событий позволяют обрабатывать конкретные взаимодействия:
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
// ...
function handleSendClick() {
sendMessage(message);
}
// ...
return (
<>
<input value={message} onChange={e => setMessage(e.target.value)} />
<button onClick={handleSendClick}>Send</button>
</>
);
} С помощью обработчика события можно быть уверенным, что sendMessage(message) будет выполняться только при нажатии пользователем кнопки.
Эффекты запускаются всякий раз, когда требуется синхронизация
Вспомните, что компонент также должен оставаться подключённым к комнате чата. Куда поместить этот код?
Причина выполнения этого кода — не какое-то конкретное взаимодействие. Неважно, почему и как пользователь перешёл на экран комнаты чата. Раз пользователь просматривает его и может с ним взаимодействовать, компонент должен оставаться подключённым к выбранному серверу чата. Даже если компонент комнаты чата был начальным экраном приложения и пользователь ещё не совершал никаких действий, подключение всё равно необходимо. Именно поэтому это эффект:
function ChatRoom({ roomId }) {
// ...
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
// ...
} С этим кодом можно быть уверенным, что активное подключение к текущему выбранному серверу чата всегда поддерживается независимо от конкретных действий пользователя. Открыл ли пользователь приложение, выбрал другую комнату или перешёл на другой экран и вернулся, эффект гарантирует, что компонент останется синхронизированным с текущей выбранной комнатой и будет подключаться повторно, когда это необходимо.
import { useState, useEffect } from 'react'; import { createConnection, sendMessage } from './chat.js'; const serverUrl = 'https://localhost:1234'; function ChatRoom({ roomId }) { const [message, setMessage] = useState(''); useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.connect(); return () => connection.disconnect(); }, [roomId]); function handleSendClick() { sendMessage(message); } return ( <> <h1>Welcome to the {roomId} room!</h1> <input value={message} onChange={e => setMessage(e.target.value)} /> <button onClick={handleSendClick}>Send</button> </> ); } export default function App() { const [roomId, setRoomId] = useState('general'); const [show, setShow] = useState(false); return ( <> <label> Choose the chat room:{' '} <select value={roomId} onChange={e => setRoomId(e.target.value)} > <option value="general">general</option> <option value="travel">travel</option> <option value="music">music</option> </select> </label> <button onClick={() => setShow(!show)}> {show ? 'Close chat' : 'Open chat'} </button> {show && <hr />} {show && <ChatRoom roomId={roomId} />} </> ); }
Реактивные значения и реактивная логика
Интуитивно можно сказать, что обработчики событий всегда запускаются «вручную», например при нажатии кнопки. Эффекты же работают «автоматически»: они запускаются и повторно запускаются столько раз, сколько нужно для поддержания синхронизации.
Есть более точный способ взглянуть на это.
Свойства, состояние и переменные, объявленные внутри тела компонента, называются реактивными значениями. В этом примере serverUrl не является реактивным значением, а roomId и message являются. Они участвуют в потоке данных рендеринга:
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
// ...
} Подобные реактивные значения могут меняться при повторном рендеринге. Например, пользователь может изменить message или выбрать другой roomId в раскрывающемся списке. Обработчики событий и эффекты по-разному реагируют на изменения:
- Логика внутри обработчиков событий не реактивна. Она не запустится снова, если пользователь не выполнит то же взаимодействие (например, нажатие) повторно. Обработчики событий могут считывать реактивные значения, не «реагируя» на их изменения.
- Логика внутри эффектов реактивна. Если эффект считывает реактивное значение, его необходимо указать как зависимость. Тогда, если при повторном рендеринге это значение изменится, React повторно запустит логику эффекта с новым значением.
Вернёмся к предыдущему примеру, чтобы проиллюстрировать эту разницу.
Логика внутри обработчиков событий не реактивна
Посмотрите на эту строку кода. Должна ли эта логика быть реактивной?
// ...
sendMessage(message);
// ... С точки зрения пользователя, изменение message не означает, что он хочет отправить сообщение. Оно означает лишь, что пользователь печатает. Иными словами, логика отправки сообщения не должна быть реактивной. Она не должна запускаться снова только потому, что изменилось реактивное значение. Поэтому ей место в обработчике события:
function handleSendClick() {
sendMessage(message);
} Обработчики событий не реактивны, поэтому sendMessage(message) будет запускаться только при нажатии пользователем кнопки «Отправить».
Логика внутри эффектов реактивна
Теперь вернёмся к этим строкам:
// ...
const connection = createConnection(serverUrl, roomId);
connection.connect();
// ... С точки зрения пользователя, изменение roomId означает, что он хочет подключиться к другой комнате. Иными словами, логика подключения к комнате должна быть реактивной. Вы хотите, чтобы эти строки кода «следовали» за реактивным значением и запускались снова, если это значение изменилось. Поэтому им место в эффекте:
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId]); Эффекты реактивны, поэтому createConnection(serverUrl, roomId) и connection.connect() будут выполняться для каждого отдельного значения roomId. Эффект поддерживает синхронизацию подключения к чату с текущей выбранной комнатой.
Извлечение нереактивной логики из эффектов
Сложности возникают, когда вы хотите смешать реактивную и нереактивную логику.
Например, представьте, что вы хотите показывать уведомление при подключении пользователя к чату. Вы считываете текущую тему (тёмную или светлую) из свойств, чтобы отобразить уведомление в правильном цвете:
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('Connected!', theme);
});
connection.connect();
// ... Однако theme — реактивное значение (оно может измениться в результате повторного рендеринга), и каждое реактивное значение, считываемое эффектом, должно быть указано в качестве его зависимости. Теперь нужно указать theme в списке зависимостей эффекта:
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('Connected!', theme);
});
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId, theme]); // ✅ All dependencies declared
// ... Попробуйте этот пример и найдите проблему с таким взаимодействием:
import { useState, useEffect } from 'react'; import { createConnection, sendMessage } from './chat.js'; import { showNotification } from './notifications.js'; const serverUrl = 'https://localhost:1234'; function ChatRoom({ roomId, theme }) { useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.on('connected', () => { showNotification('Connected!', theme); }); connection.connect(); return () => connection.disconnect(); }, [roomId, theme]); return <h1>Welcome to the {roomId} room!</h1> } export default function App() { const [roomId, setRoomId] = useState('general'); const [isDark, setIsDark] = useState(false); return ( <> <label> Choose the chat room:{' '} <select value={roomId} onChange={e => setRoomId(e.target.value)} > <option value="general">general</option> <option value="travel">travel</option> <option value="music">music</option> </select> </label> <label> <input type="checkbox" checked={isDark} onChange={e => setIsDark(e.target.checked)} /> Use dark theme </label> <hr /> <ChatRoom roomId={roomId} theme={isDark ? 'dark' : 'light'} /> </> ); }
Когда меняется roomId, чат подключается заново, как и ожидалось. Но поскольку theme тоже является зависимостью, чат также подключается заново при каждом переключении между тёмной и светлой темами. Это не очень хорошо!
Иными словами, вы не хотите, чтобы эта строка была реактивной, хотя она находится внутри эффекта (который реактивен):
// ...
showNotification('Connected!', theme);
// ... Нужен способ отделить эту нереактивную логику от окружающего её реактивного эффекта.
Объявление события эффекта
Используйте специальный хук useEffectEvent, чтобы извлечь эту нереактивную логику из эффекта:
import { useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});
// ... Здесь onConnected называется событием эффекта. Это часть логики эффекта, но ведёт она себя скорее как обработчик события. Логика внутри него не реактивна и всегда «видит» последние значения свойств и состояния.
Теперь можно вызывать событие эффекта onConnected внутри эффекта:
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
onConnected();
});
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ All dependencies declared
// ... Это решает проблему. Обратите внимание, что theme нужно было удалить из списка зависимостей эффекта, поскольку теперь оно не используется в эффекте. Также не нужно добавлять туда onConnected, потому что события эффектов не реактивны и их не следует указывать в зависимостях.
Проверьте, что новое поведение работает так, как ожидалось:
import { useState, useEffect } from 'react'; import { useEffectEvent } from 'react'; import { createConnection, sendMessage } from './chat.js'; import { showNotification } from './notifications.js'; const serverUrl = 'https://localhost:1234'; function ChatRoom({ roomId, theme }) { const onConnected = useEffectEvent(() => { showNotification('Connected!', theme); }); useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.on('connected', () => { onConnected(); }); connection.connect(); return () => connection.disconnect(); }, [roomId]); return <h1>Welcome to the {roomId} room!</h1> } export default function App() { const [roomId, setRoomId] = useState('general'); const [isDark, setIsDark] = useState(false); return ( <> <label> Choose the chat room:{' '} <select value={roomId} onChange={e => setRoomId(e.target.value)} > <option value="general">general</option> <option value="travel">travel</option> <option value="music">music</option> </select> </label> <label> <input type="checkbox" checked={isDark} onChange={e => setIsDark(e.target.checked)} /> Use dark theme </label> <hr /> <ChatRoom roomId={roomId} theme={isDark ? 'dark' : 'light'} /> </> ); }
События эффектов можно считать очень похожими на обработчики событий. Основное отличие в том, что обработчики событий запускаются в ответ на взаимодействия пользователя, а события эффектов вызываются вами из эффектов. События эффектов позволяют «разорвать цепочку» между реактивностью эффектов и кодом, который не должен быть реактивным.
Чтение последних значений свойств и состояния с помощью событий эффектов
События эффектов помогают исправить множество ситуаций, когда может возникнуть соблазн отключить проверку зависимостей линтером.
Например, допустим, у вас есть эффект для регистрации посещений страниц:
function Page() {
useEffect(() => {
logVisit();
}, []);
// ...
} Позже вы добавляете на сайт несколько маршрутов. Теперь компонент Page получает свойство url с текущим путём. Вы хотите передать url как часть вызова logVisit, но линтер зависимостей выдаёт предупреждение:
function Page({ url }) {
useEffect(() => {
logVisit(url);
}, []); // 🔴 React Hook useEffect has a missing dependency: 'url'
// ...
} Подумайте, что должен делать код. Вы хотите регистрировать отдельное посещение для каждого URL, поскольку каждый URL соответствует отдельной странице. Иными словами, этот вызов logVisit должен быть реактивным относительно url. Поэтому в данном случае имеет смысл следовать рекомендациям линтера зависимостей и добавить url в список зависимостей:
function Page({ url }) {
useEffect(() => {
logVisit(url);
}, [url]); // ✅ All dependencies declared
// ...
} Теперь предположим, что вы хотите учитывать количество товаров в корзине при каждом посещении страницы:
function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;
useEffect(() => {
logVisit(url, numberOfItems);
}, [url]); // 🔴 React Hook useEffect has a missing dependency: 'numberOfItems'
// ...
} Вы использовали numberOfItems внутри эффекта, поэтому линтер предлагает добавить его в список зависимостей. Однако вы не хотите, чтобы вызов logVisit был реактивным относительно numberOfItems. Если пользователь добавит что-нибудь в корзину и numberOfItems изменится, это не означает, что пользователь снова посетил страницу. Иными словами, посещение страницы в некотором смысле является «событием». Оно происходит в конкретный момент времени.
Разделите код на две части:
function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;
const onVisit = useEffectEvent(visitedUrl => {
logVisit(visitedUrl, numberOfItems);
});
useEffect(() => {
onVisit(url);
}, [url]); // ✅ All dependencies declared
// ...
} Здесь onVisit — событие эффекта. Код внутри него не реактивен. Поэтому можно использовать numberOfItems (или любое другое реактивное значение!), не опасаясь, что при изменении оно вызовет повторное выполнение окружающего кода.
В то же время сам эффект остаётся реактивным. Код внутри эффекта использует свойство url, поэтому эффект будет запускаться повторно после каждого рендеринга с другим значением url. Это, в свою очередь, вызовет событие эффекта onVisit.
В результате вызов logVisit будет происходить при каждом изменении url, а numberOfItems всегда будет считываться в актуальном значении. Однако если numberOfItems изменится само по себе, это не приведёт к повторному выполнению кода.
Примечание
Возможно, вы задаётесь вопросом, можно ли вызвать onVisit() без аргументов и считывать внутри него url:
const onVisit = useEffectEvent(() => {
logVisit(url, numberOfItems);
});
useEffect(() => {
onVisit();
}, [url]);
Это сработает, но лучше явно передавать url событию эффекта. Передавая url в качестве аргумента события эффекта, вы сообщаете, что посещение страницы с другим url с точки зрения пользователя является отдельным «событием». visitedUrl — это часть произошедшего «события»:
const onVisit = useEffectEvent(visitedUrl => {
logVisit(visitedUrl, numberOfItems);
});
useEffect(() => {
onVisit(url);
}, [url]);
Поскольку событие эффекта явно «запрашивает» visitedUrl, вы уже не сможете случайно удалить url из списка зависимостей эффекта. Если убрать зависимость url (из-за чего отдельные посещения страниц будут считаться одним), линтер предупредит вас об этом. Вы хотите, чтобы onVisit был реактивным относительно url, поэтому вместо считывания url внутри (где оно не было бы реактивным) передавайте его из эффекта.
Это особенно важно, если внутри эффекта есть асинхронная логика:
const onVisit = useEffectEvent(visitedUrl => {
logVisit(visitedUrl, numberOfItems);
});
useEffect(() => {
setTimeout(() => {
onVisit(url);
}, 5000); // Delay logging visits
}, [url]);
Здесь url внутри onVisit соответствует последнему значению url (которое уже могло измениться), а visitedUrl соответствует url, изначально вызвавшему выполнение этого эффекта (и вызова onVisit).
Подробнее
Можно ли вместо этого отключить проверку зависимостей линтером?
В существующих кодовых базах иногда можно встретить отключение правила линтера, например:
function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;
useEffect(() => {
logVisit(url, numberOfItems);
// 🔴 Avoid suppressing the linter like this:
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
// ...
}
Мы рекомендуем никогда не отключать линтер.
Первый недостаток отключения правила заключается в том, что React больше не будет предупреждать вас, когда эффекту потребуется «реагировать» на новую реактивную зависимость, добавленную в код. В предыдущем примере вы добавили url в зависимости, потому что React напомнил вам об этом. Если отключить линтер, при будущих изменениях эффекта таких напоминаний больше не будет. Это приводит к ошибкам.
Вот пример запутанной ошибки, вызванной отключением линтера. В этом примере функция handleMove должна считывать текущее значение переменной состояния canMove, чтобы определить, должна ли точка следовать за курсором. Однако внутри handleMove значение canMove всегда равно true.
Понимаете почему?
import { useState, useEffect } from 'react'; export default function App() { const [position, setPosition] = useState({ x: 0, y: 0 }); const [canMove, setCanMove] = useState(true); function handleMove(e) { if (canMove) { setPosition({ x: e.clientX, y: e.clientY }); } } useEffect(() => { window.addEventListener('pointermove', handleMove); return () => window.removeEventListener('pointermove', handleMove); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( <> <label> <input type="checkbox" checked={canMove} onChange={e => setCanMove(e.target.checked)} /> The dot is allowed to move </label> <hr /> <div style={{ position: 'absolute', backgroundColor: 'pink', borderRadius: '50%', opacity: 0.6, transform: `translate(${position.x}px, ${position.y}px)`, pointerEvents: 'none', left: -20, top: -20, width: 40, height: 40, }} /> </> ); }
Проблема этого кода — в отключении проверки зависимостей линтером. Если убрать отключение, вы увидите, что этот эффект должен зависеть от функции handleMove. Это логично: handleMove объявлена внутри тела компонента, поэтому является реактивным значением. Каждое реактивное значение необходимо указывать в качестве зависимости, иначе со временем оно может устареть!
Автор исходного кода «солгал» React, заявив, что эффект не зависит ([]) ни от каких реактивных значений. Поэтому React не синхронизировал эффект повторно после изменения canMove (и вместе с ним handleMove). Поскольку React не синхронизировал эффект повторно, назначенная в качестве обработчика функция handleMove — это функция handleMove, созданная при первоначальном рендеринге. Во время первоначального рендеринга значение canMove было true, поэтому handleMove из первоначального рендеринга навсегда будет видеть это значение.
Если никогда не отключать линтер, вы не столкнётесь с проблемами из-за устаревших значений.
С помощью useEffectEvent не нужно «лгать» линтеру, и код работает так, как ожидается:
import { useState, useEffect } from 'react'; import { useEffectEvent } from 'react'; export default function App() { const [position, setPosition] = useState({ x: 0, y: 0 }); const [canMove, setCanMove] = useState(true); const onMove = useEffectEvent(e => { if (canMove) { setPosition({ x: e.clientX, y: e.clientY }); } }); useEffect(() => { window.addEventListener('pointermove', onMove); return () => window.removeEventListener('pointermove', onMove); }, []); return ( <> <label> <input type="checkbox" checked={canMove} onChange={e => setCanMove(e.target.checked)} /> The dot is allowed to move </label> <hr /> <div style={{ position: 'absolute', backgroundColor: 'pink', borderRadius: '50%', opacity: 0.6, transform: `translate(${position.x}px, ${position.y}px)`, pointerEvents: 'none', left: -20, top: -20, width: 40, height: 40, }} /> </> ); }
Это не означает, что useEffectEvent — всегда правильное решение. Используйте его только для тех строк кода, которые не должны быть реактивными. В приведённой выше песочнице вы не хотели, чтобы код эффекта был реактивным относительно canMove. Поэтому извлечение события эффекта имело смысл.
Другие корректные альтернативы отключению линтера см. в разделе Удаление зависимостей эффекта.
Ограничения событий эффектов
Возможности использования событий эффектов весьма ограничены:
- Вызывайте их только внутри эффектов.
- Никогда не передавайте их другим компонентам или хукам.
Например, не объявляйте событие эффекта и не передавайте его вот так:
function Timer() {
const [count, setCount] = useState(0);
const onTick = useEffectEvent(() => {
setCount(count + 1);
});
useTimer(onTick, 1000); // 🔴 Avoid: Passing Effect Events
return <h1>{count}</h1>
}
function useTimer(callback, delay) {
useEffect(() => {
const id = setInterval(() => {
callback();
}, delay);
return () => {
clearInterval(id);
};
}, [delay, callback]); // Need to specify "callback" in dependencies
} Вместо этого всегда объявляйте события эффектов непосредственно рядом с эффектами, в которых они используются:
function Timer() {
const [count, setCount] = useState(0);
useTimer(() => {
setCount(count + 1);
}, 1000);
return <h1>{count}</h1>
}
function useTimer(callback, delay) {
const onTick = useEffectEvent(() => {
callback();
});
useEffect(() => {
const id = setInterval(() => {
onTick(); // ✅ Good: Only called locally inside an Effect
}, delay);
return () => {
clearInterval(id);
};
}, [delay]); // No need to specify "onTick" (an Effect Event) as a dependency
} События эффектов — это нереактивные «фрагменты» кода эффекта. Их следует размещать рядом с использующим их эффектом.
Итоги
- Обработчики событий запускаются в ответ на конкретные взаимодействия.
- Эффекты запускаются всякий раз, когда требуется синхронизация.
- Логика внутри обработчиков событий не реактивна.
- Логика внутри эффектов реактивна.
- Нереактивную логику можно перенести из эффектов в события эффектов.
- Вызывайте события эффектов только внутри эффектов.
- Не передавайте события эффектов другим компонентам или хукам.
Попробуйте решить несколько задач
Задача 1 из 4:
Исправьте переменную, которая не обновляется
Этот компонент Timer хранит переменную состояния count, значение которой увеличивается каждую секунду. Значение, на которое оно увеличивается, хранится в переменной состояния increment. Вы можете управлять переменной increment с помощью кнопок «плюс» и «минус».
Однако, сколько бы раз вы ни нажимали кнопку «плюс», счетчик каждую секунду увеличивается на единицу. Что не так с этим кодом? Почему внутри кода Effect значение increment всегда равно 1? Найдите ошибку и исправьте её.
import { useState, useEffect } from 'react'; export default function Timer() { const [count, setCount] = useState(0); const [increment, setIncrement] = useState(1); useEffect(() => { const id = setInterval(() => { setCount(c => c + increment); }, 1000); return () => { clearInterval(id); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( <> <h1> Counter: {count} <button onClick={() => setCount(0)}>Reset</button> </h1> <hr /> <p> Every second, increment by: <button disabled={increment === 0} onClick={() => { setIncrement(i => i - 1); }}>–</button> <b>{increment}</b> <button onClick={() => { setIncrement(i => i + 1); }}>+</button> </p> </> ); }
© 2013–present Facebook Inc.
Licensed under the Creative Commons Attribution 4.0 International Public License.
https://react.dev/learn/separating-events-from-effects