- Quick Start
- Installation
- Describing the UI
- Adding Interactivity
- Managing State
- Escape Hatches
GET STARTED
LEARN REACT
Відокремлення подій від ефектів
Обробники подій запускаються повторно лише тоді, коли ви виконуєте ту саму взаємодію знову. На відміну від обробників подій, ефекти пересинхронізуються, якщо якесь значення, яке вони зчитують, наприклад, проп або змінна стану, відрізняється від того, що було під час останнього рендерингу. Іноді вам також буде потрібен мікс обох типів поведінки: ефект, який повторно запускається у відповідь на деякі значення, але не запускається на інші. На цій сторінці ви дізнаєтеся, як це зробити.
- Як вибрати між обробником події та ефектом
- Чому ефекти є реактивними, а обробники подій - ні
- Що робити, якщо ви хочете, щоб частина коду ефекту не була реактивною
- Що таке події ефектів і як їх витягувати з ефектів
- Як прочитати останні пропси та стан з ефектів за допомогою подій ефектів
Вибір між обробниками подій та ефектами
Спочатку нагадаємо різницю між обробниками подій та ефектами.
Уявіть, що ви розробляєте компонент чату. Ваші вимоги виглядають наступним чином:
- Ваш компонент має автоматично підключатися до обраної кімнати чату.
- Коли ви натискаєте кнопку "Відправити", вона має відправити повідомлення в чат.
Припустимо, ви вже реалізували код для них, але не знаєте, куди його вставити. Чи варто використовувати обробники подій або ефекти? Щоразу, коли вам потрібно відповісти на це питання, подумайте чому код повинен працювати.
Обробники подій запускаються у відповідь на певні взаємодії
З точки зору користувача, відправлення повідомлення має відбутися тому що було натиснуто конкретну кнопку "Відправити". Користувач дуже засмутиться, якщо ви надішлете його повідомлення у будь-який інший час або з будь-якої іншої причини. Ось чому надсилання повідомлення має бути обробником події. Обробники подій дозволяють вам обробляти специфічні взаємодії:
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} />}
</>
);
}
export function sendMessage(message) {
console.log('🔵 You sent: ' + message);
}
export function createConnection(serverUrl, roomId) {
// A real implementation would actually connect to the server
return {
connect() {
console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...');
},
disconnect() {
console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl);
}
};
}
input, select { margin-right: 20px; }
Реактивні значення та реактивна логіка
Інтуїтивно можна сказати, що обробники подій завжди запускаються "вручну", наприклад, натисканням кнопки. Натомість ефекти є "автоматичними": вони запускаються і повторюються стільки разів, скільки потрібно для синхронізації.
Існує більш точний спосіб подумати про це.
Пропси, стан та змінні, оголошені у тілі вашого компонента, називаються roomId
і message
є. Вони беруть участь у потоці даних рендерингу:
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
// ...
}
Такі реактивні значення можуть змінюватися внаслідок повторного рендерингу. Наприклад, користувач може відредагувати повідомлення
або обрати інше roomId
у випадаючому списку. Обробники подій та ефекти реагують на зміни по-різному:
- Логіка всередині обробників подій не є реактивною. Вона не запуститься знову, доки користувач не виконає ту саму взаємодію (наприклад, натискання) знову. Обробники подій можуть читати реактивні значення, не "реагуючи" на їхні зміни.
- Логіка всередині ефектів є реактивною. Якщо ваш ефект читає реактивне значення, ви повинні вказати його як залежність. Тоді, якщо повторний рендеринг призведе до зміни цього значення, React перезапустить логіку вашого ефекту з новим значенням.
Повернімося до попереднього прикладу, щоб проілюструвати цю різницю.
Логіка всередині обробників подій не є реактивною
Погляньте на цей рядок коду. Ця логіка має бути реактивною чи ні?
// ...
sendMessage(message);
// ...
З точки зору користувача, зміна у Обробники подій не є реактивними, тому А тепер повернімося до цих рядків: З точки зору користувача, зміна на Ефекти є реактивними, тому Справа ускладнюється, коли ви хочете змішати реактивну логіку з нереактивною. Наприклад, уявіть, що ви хочете показати сповіщення, коли користувач підключається до чату. Ви зчитуєте поточну тему (темну або світлу) з пропсів, щоб показати сповіщення відповідним кольором: Втім, Пограйтеся з цим прикладом і подивіться, чи зможете ви виявити проблему у цьому користувацькому досвіді:повідомленні
не означає, що він хоче надіслати повідомлення. Це лише означає, що користувач вводить текст. Іншими словами, логіка, яка надсилає повідомлення, не повинна бути реактивною. Вона не повинна запускатися знову тільки тому, що sendMessage(message)
буде запущено лише тоді, коли користувач натисне кнопку Надіслати.Логіка всередині Effects є реактивною
// ...
const connection = createConnection(serverUrl, roomId);
connection.connect();
// ...
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();
// ...
тема
є реактивною величиною (вона може змінюватися в результаті повторного рендерингу), а кожна реактивна величина, яку зчитує Ефект, має бути оголошена як його залежність. Тепер вам слід вказати тему
як залежність вашого ефекту: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
// ...
{
"dependencies": {
"react": "latest",
"react-dom": "latest",
"react-scripts": "latest",
"toastify-js": "1.12.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}
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'}
/>
</>
);
}
export function createConnection(serverUrl, roomId) {
// A real implementation would actually connect to the server
let connectedCallback;
let timeout;
return {
connect() {
timeout = setTimeout(() => {
if (connectedCallback) {
connectedCallback();
}
}, 100);
},
on(event, callback) {
if (connectedCallback) {
throw Error('Cannot add the handler twice.');
}
if (event !== 'connected') {
throw Error('Only "connected" event is supported.');
}
connectedCallback = callback;
},
disconnect() {
clearTimeout(timeout);
}
};
}
import Toastify from 'toastify-js';
import 'toastify-js/src/toastify.css';
export function showNotification(message, theme) {
Toastify({
text: message,
duration: 2000,
gravity: 'top',
position: 'right',
style: {
background: theme === 'dark' ? 'black' : 'white',
color: theme === 'dark' ? 'white' : 'black',
},
}).showToast();
}
label { display: block; margin-top: 10px; }
Коли змінюється roomId
, чат перепідключається, як і слід було очікувати. Але оскільки тема
також є залежною, чат також перепідключається щоразу, коли ви перемикаєтеся між темною та світлою темами. Це не дуже добре!
Інакше кажучи, ви не хочете, щоб цей рядок був реактивним, хоча він знаходиться всередині ефекту (який є реактивним):
// ...
showNotification('Connected!', theme);
// ...
Вам потрібен спосіб відокремити цю нереактивну логіку від реактивного ефекту навколо неї.
Оголошення події ефекту
Цей розділ описує експериментальний API, який ще не було випущено у стабільній версії React.
Використовуйте спеціальний хук 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
// ...
Це вирішує проблему. Зверніть увагу, що вам слід було видалити onConnected
зі списку залежностей вашого ефекту. Події ефекту не є реактивними і їх слід вилучити із залежностей.
Переконайтеся, що нова поведінка працює так, як ви очікували:
{
"dependencies": {
"react": "experimental",
"react-dom": "experimental",
"react-scripts": "latest",
"toastify-js": "1.12.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}
import { useState, useEffect } from 'react';
import { experimental_useEffectEvent as 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'}
/>
</>
);
}
export function createConnection(serverUrl, roomId) {
// A real implementation would actually connect to the server
let connectedCallback;
let timeout;
return {
connect() {
timeout = setTimeout(() => {
if (connectedCallback) {
connectedCallback();
}
}, 100);
},
on(event, callback) {
if (connectedCallback) {
throw Error('Cannot add the handler twice.');
}
if (event !== 'connected') {
throw Error('Only "connected" event is supported.');
}
connectedCallback = callback;
},
disconnect() {
clearTimeout(timeout);
}
};
}
import Toastify from 'toastify-js';
import 'toastify-js/src/toastify.css';
export function showNotification(message, theme) {
Toastify({
text: message,
duration: 2000,
gravity: 'top',
position: 'right',
style: {
background: theme === 'dark' ? 'black' : 'white',
color: theme === 'dark' ? 'white' : 'black',
},
}).showToast();
}
label { display: block; margin-top: 10px; }
Ви можете думати про події ефектів як про дуже схожі на обробники подій. Основна відмінність полягає у тому, що обробники подій запускаються у відповідь на дії користувача, тоді як події ефектів запускаються вами з ефектів. Події ефектів дозволяють вам "розірвати ланцюг" між реактивністю ефектів і кодом, який не повинен реагувати.
Зчитування останніх пропсів та станів за допомогою подій ефектів
Цей розділ описує експериментальний API, який ще не було випущено у стабільній версії React.
Події ефектів дозволяють виправити багато патернів, у яких може виникнути спокуса приховати літер залежності.
Наприклад, скажімо, у вас є ефект для реєстрації відвідувань сторінки:
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
Effect Event.
У результаті ви будете викликати logVisit
для кожної зміни адреси
і завжди зчитуватимете останню версію numberOfItems
. Однак, якщо numberOfItems
зміниться сам по собі, це не призведе до повторного запуску коду.
Вам може бути цікаво, чи можна викликати onVisit()
без аргументів і прочитати url
всередині нього:
const onVisit = useEffectEvent(() => {
logVisit(url, numberOfItems);
});
useEffect(() => {
onVisit();
}, [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
).
Чи можна замість цього придушити лінтер залежності?
В існуючих кодових базах ви можете іноді бачити, що правило lint пригнічено таким чином:
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]);
// ...
}
Після того, як useEffectEvent
стане стабільною частиною React, ми рекомендуємо ніколи не пригнічувати лінтер.
Першим недоліком придушення правила є те, що React більше не буде попереджати вас, коли ваш ефект має "відреагувати" на нову реактивну залежність, яку ви додали до свого коду. У попередньому прикладі ви додали url
до залежностей , тому що React нагадав вам про це. Якщо ви вимкнете лінтер, то більше не отримуватимете таких нагадувань для будь-яких майбутніх редагувань з цим ефектом. Це призводить до проблем.
Ось приклад заплутаної помилки, спричиненої придушенням лінтеру. У цьому прикладі функція handleMove
має прочитати поточне значення змінної стану canMove
, щоб вирішити, чи слідувати крапці за курсором. Однак, canMove
завжди істинна
всередині handleMove
.
Чи можете ви пояснити чому?
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,
}} />
</>
);
}
body {
height: 200px;
}
Проблема у цьому коді полягає у придушенні залежностей лінтера. Якщо ви знімете придушення, то побачите, що цей ефект має залежати від функції handleMove
. Це має сенс: handleMove
оголошено всередині тіла компонента, що робить її реактивним значенням. Кожне реактивне значення має бути вказане як залежність, інакше воно може стати неактуальним з часом!
Автор оригінального коду "збрехав" React, сказавши, що ефект не залежить ([]
) від будь-яких реактивних значень. Ось чому React не пересинхронізував ефект після зміни canMove
(і handleMove
разом з ним). Оскільки React не пересинхронізував ефект, handleMove
, приєднаний як слухач, є функцією handleMove
, створеною під час початкового рендерингу. Під час початкового відображення canMove
було істинним
, тому handleMove
з початкового відображення бачитиме це значення завжди.
Якщо ви ніколи не пригнічуєте лінтер, ви ніколи не побачите проблем із застарілими значеннями.
За допомогою useEffectEvent
не потрібно "брехати" лінтеру, і код працює так, як ви очікуєте:
{
"dependencies": {
"react": "experimental",
"react-dom": "experimental",
"react-scripts": "latest"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}
import { useState, useEffect } from 'react';
import { experimental_useEffectEvent as 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,
}} />
</>
);
}
body {
height: 200px;
}
Це не означає, що useEffectEvent
є завжди правильним рішенням. Ви повинні застосовувати його лише до тих рядків коду, які не повинні реагувати. У наведеній вище пісочниці ви не хотіли, щоб код ефекту реагував на canMove
. Ось чому є сенс витягти подію ефекту.
Читайте Видалення залежностей ефектів для інших коректних альтернатив придушення лінтеру.
Обмеження подій впливу
Цей розділ описує експериментальний API, який ще не було випущено у стабільній версії React.
Можливості використання подій ефектів дуже обмежені:
- Викликайте їх лише зсередини ефектів.
- Ніколи не передавайте їх іншим компонентам або хукам.
Наприклад, не оголошуйте і не передавайте таку подію ефекту:
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
}
Події ефекту - це нереактивні "шматки" коду ефекту. Вони мають бути поруч з ефектом, який їх використовує.
- Обробники подій запускаються у відповідь на певні взаємодії.
- Ефекти запускаються щоразу, коли потрібна синхронізація.
- Логіка всередині обробників подій не є реактивною.
- Логіка всередині Effects є реактивною.
- Ви можете перемістити нереактивну логіку з ефектів до подій ефектів.
- Викликайте події ефектів лише зсередини ефектів.
- Не передавайте події ефекту іншим компонентам або хукам.
Виправити змінну, яка не оновлюється
.Цей Timer
компонент зберігає змінну стану count
, яка збільшується щосекунди. Значення, на яке вона збільшується, зберігається у змінній стану increment
. Керувати змінною increment
можна за допомогою кнопок плюс і мінус.
Проте, скільки б разів ви не натискали кнопку плюс, лічильник все одно збільшується на одиницю щосекунди. Що не так з цим кодом? Чому 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>
</>
);
}
button { margin: 10px; }
Як завжди, коли ви шукаєте вади в ефектах, почніть з пошуку придушення літер.
Якщо ви видалите коментар придушення, React скаже вам, що код цього ефекту залежить від інкременту
, але ви "збрехали" React, заявивши, що цей ефект не залежить від жодних реактивних значень ([]
). Додайте інкремент
до масиву залежностей:
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);
};
}, [increment]);
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>
</>
);
}
button { margin: 10px; }
Тепер, коли інкремент
змінюється, React пересинхронізує ваш ефект, що перезапустить інтервал.
Виправити лічильник зависання
Цей Timer
компонент зберігає змінну стану count
, яка збільшується щосекунди. Значення, на яке вона збільшується, зберігається у змінній стану increment
, якою ви можете керувати за допомогою кнопок плюс та мінус. Наприклад, спробуйте натиснути кнопку плюс дев'ять разів і відмітьте, що count
тепер збільшується щосекунди на десять, а не на одиницю.
У цьому інтерфейсі користувача є невелика проблема. Ви можете помітити, що якщо ви продовжуєте натискати кнопки "плюс" або "мінус" швидше, ніж раз на секунду, сам таймер призупиняється. Він відновлюється лише після того, як мине секунда з моменту останнього натискання будь-якої з кнопок. З'ясуйте, чому це відбувається, і виправте проблему так, щоб таймер цокав кожну секунду без переривань.
Здається, що ефект, який налаштовує таймер, "реагує" на значення інкременту
. Чи дійсно рядок, який використовує поточне значення increment
для виклику setCount
, повинен реагувати?
{
"dependencies": {
"react": "experimental",
"react-dom": "experimental",
"react-scripts": "latest"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}
import { useState, useEffect } from 'react';
import { experimental_useEffectEvent as useEffectEvent } 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);
};
}, [increment]);
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>
</>
);
}
button { margin: 10px; }
Проблема в тому, що код всередині ефекту використовує змінну стану increment
. Оскільки вона залежить від вашого ефекту, кожна зміна increment
призводить до пересинхронізації ефекту, що призводить до очищення інтервалу. Якщо ви продовжуватимете очищати інтервал щоразу до того, як він встигне спрацювати, це виглядатиме так, ніби таймер зупинився.
Щоб вирішити проблему, витягніть подію ефекту onTick
з ефекту:
{
"dependencies": {
"react": "experimental",
"react-dom": "experimental",
"react-scripts": "latest"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}
import { useState, useEffect } from 'react';
import { experimental_useEffectEvent as useEffectEvent } from 'react';
export default function Timer() {
const [count, setCount] = useState(0);
const [increment, setIncrement] = useState(1);
const onTick = useEffectEvent(() => {
setCount(c => c + increment);
});
useEffect(() => {
const id = setInterval(() => {
onTick();
}, 1000);
return () => {
clearInterval(id);
};
}, []);
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>
</>
);
}
button { margin: 10px; }
Оскільки onTick
є подією ефекту, код у ньому не є реактивним. Зміна на інкремент
не викликає жодних ефектів.
Виправлено затримку, яку неможливо відрегулювати
У цьому прикладі ви можете налаштувати затримку інтервалу. Вона зберігається у змінній стану delay
, яка оновлюється двома кнопками. Однак, навіть якщо ви натискатимете кнопку "плюс 100 мс", доки delay
не стане рівною 1000 мілісекунд (тобто секунді), ви помітите, що таймер все одно збільшується дуже швидко (кожні 100 мс). Це виглядає так, ніби ваші зміни у delay
ігноруються. Знайдіть і виправте помилку.
Код всередині подій ефекту не реагує. Чи існують випадки, у яких вам потрібно повторно виконати виклик setInterval
?
{
"dependencies": {
"react": "experimental",
"react-dom": "experimental",
"react-scripts": "latest"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}
import { useState, useEffect } from 'react';
import { experimental_useEffectEvent as useEffectEvent } from 'react';
export default function Timer() {
const [count, setCount] = useState(0);
const [increment, setIncrement] = useState(1);
const [delay, setDelay] = useState(100);
const onTick = useEffectEvent(() => {
setCount(c => c + increment);
});
const onMount = useEffectEvent(() => {
return setInterval(() => {
onTick();
}, delay);
});
useEffect(() => {
const id = onMount();
return () => {
clearInterval(id);
}
}, []);
return (
<>
<h1>
Counter: {count}
<button onClick={() => setCount(0)}>Reset</button>
</h1>
<hr />
<p>
Increment by:
<button disabled={increment === 0} onClick={() => {
setIncrement(i => i - 1);
}}>–</button>
<b>{increment}</b>
<button onClick={() => {
setIncrement(i => i + 1);
}}>+</button>
</p>
<p>
Increment delay:
<button disabled={delay === 100} onClick={() => {
setDelay(d => d - 100);
}}>–100 ms</button>
<b>{delay} ms</b>
<button onClick={() => {
setDelay(d => d + 100);
}}>+100 ms</button>
</p>
</>
);
}
button { margin: 10px; }
Проблема у наведеному вище прикладі полягає у тому, що було вилучено подію ефекту onMount
без урахування того, що насправді має робити код. Витягувати події ефектів слід лише з певної причини: коли ви хочете зробити частину коду нереагуючою. Однак виклик setInterval
should має бути реактивним щодо змінної стану delay
. Якщо delay
змінюється, вам потрібно встановити інтервал з нуля! Щоб виправити цей код, витягніть весь реактивний код назад всередину ефекту:
{
"dependencies": {
"react": "experimental",
"react-dom": "experimental",
"react-scripts": "latest"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}
import { useState, useEffect } from 'react';
import { experimental_useEffectEvent as useEffectEvent } from 'react';
export default function Timer() {
const [count, setCount] = useState(0);
const [increment, setIncrement] = useState(1);
const [delay, setDelay] = useState(100);
const onTick = useEffectEvent(() => {
setCount(c => c + increment);
});
useEffect(() => {
const id = setInterval(() => {
onTick();
}, delay);
return () => {
clearInterval(id);
}
}, [delay]);
return (
<>
<h1>
Counter: {count}
<button onClick={() => setCount(0)}>Reset</button>
</h1>
<hr />
<p>
Increment by:
<button disabled={increment === 0} onClick={() => {
setIncrement(i => i - 1);
}}>–</button>
<b>{increment}</b>
<button onClick={() => {
setIncrement(i => i + 1);
}}>+</button>
</p>
<p>
Increment delay:
<button disabled={delay === 100} onClick={() => {
setDelay(d => d - 100);
}}>–100 ms</button>
<b>{delay} ms</b>
<button onClick={() => {
setDelay(d => d + 100);
}}>+100 ms</button>
</p>
</>
);
}
button { margin: 10px; }
Загалом, вам слід з підозрою ставитися до функцій на зразок onMount
, які зосереджуються на часі, а не на призначенні частини коду. Спочатку це може здатися "більш описовим", але це приховує ваші наміри. Як правило, події ефекту повинні відповідати тому, що відбувається з точки зору користувача. Наприклад, onMessage
, onTick
, onVisit
або onConnected
є гарними назвами подій ефекту. Код всередині них, швидше за все, не повинен бути реактивним. З іншого боку, onMount
, onUpdate
, onUnmount
або onAfterRender
настільки загальні, що в них легко випадково помістити код, який повинен бути реактивним. Ось чому вам слід називати події ефекту на честь того, що, на думку користувача, сталося, а не на честь того, що якийсь код випадково було запущено.
Виправити запізніле сповіщення
Коли ви приєднуєтеся до чату, цей компонент відображає сповіщення. Однак, він не робить це одразу. Замість цього сповіщення штучно затримується на дві секунди, щоб користувач мав можливість оглянути інтерфейс.
Це майже працює, але є помилка. Спробуйте дуже швидко змінити випадаючий список з "загальні" на "подорожі", а потім на "музика". Якщо ви зробите це досить швидко, ви побачите два сповіщення (як і очікувалося!), але вони будуть обидва з написом "Ласкаво просимо до музики".
Виправте це так, щоб при швидкому перемиканні з "загальних" на "подорожі", а потім на "музику", ви бачили два сповіщення, перше з яких "Ласкаво просимо до подорожей", а друге - "Ласкаво просимо до музики". (Для додаткового завдання, припускаючи, що ви вже зробили так, щоб сповіщення показували правильні кімнати, змініть код так, щоб відображалося лише останнє сповіщення.)
Ваш Effect знає, до якої кімнати він підключився. Чи є якась інформація, яку ви хочете передати події ефекту?
{
"dependencies": {
"react": "experimental",
"react-dom": "experimental",
"react-scripts": "latest",
"toastify-js": "1.12.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}
import { useState, useEffect } from 'react';
import { experimental_useEffectEvent as 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('Welcome to ' + roomId, theme);
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
setTimeout(() => {
onConnected();
}, 2000);
});
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'}
/>
</>
);
}
export function createConnection(serverUrl, roomId) {
// A real implementation would actually connect to the server
let connectedCallback;
let timeout;
return {
connect() {
timeout = setTimeout(() => {
if (connectedCallback) {
connectedCallback();
}
}, 100);
},
on(event, callback) {
if (connectedCallback) {
throw Error('Cannot add the handler twice.');
}
if (event !== 'connected') {
throw Error('Only "connected" event is supported.');
}
connectedCallback = callback;
},
disconnect() {
clearTimeout(timeout);
}
};
}
import Toastify from 'toastify-js';
import 'toastify-js/src/toastify.css';
export function showNotification(message, theme) {
Toastify({
text: message,
duration: 2000,
gravity: 'top',
position: 'right',
style: {
background: theme === 'dark' ? 'black' : 'white',
color: theme === 'dark' ? 'white' : 'black',
},
}).showToast();
}
label { display: block; margin-top: 10px; }
Всередині вашої події ефекту, roomId
це значення на момент виклику події ефекту.
Ваша подія ефекту викликається з двосекундною затримкою. Якщо ви швидко переходите з кімнати подорожей до музичної кімнати, на момент показу сповіщення кімнати подорожей roomId
вже буде "music"
. Ось чому в обох сповіщеннях написано "Ласкаво просимо до музичної кімнати".
Щоб виправити проблему, замість того, щоб читати latest roomId
всередині події ефекту, зробіть його параметром вашої події ефекту, як connectedRoomId
нижче. Потім передайте roomId
з вашого ефекту за допомогою виклику onConnected(roomId)
:
{
"dependencies": {
"react": "experimental",
"react-dom": "experimental",
"react-scripts": "latest",
"toastify-js": "1.12.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}
import { useState, useEffect } from 'react';
import { experimental_useEffectEvent as 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(connectedRoomId => {
showNotification('Welcome to ' + connectedRoomId, theme);
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
setTimeout(() => {
onConnected(roomId);
}, 2000);
});
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'}
/>
</>
);
}
export function createConnection(serverUrl, roomId) {
// A real implementation would actually connect to the server
let connectedCallback;
let timeout;
return {
connect() {
timeout = setTimeout(() => {
if (connectedCallback) {
connectedCallback();
}
}, 100);
},
on(event, callback) {
if (connectedCallback) {
throw Error('Cannot add the handler twice.');
}
if (event !== 'connected') {
throw Error('Only "connected" event is supported.');
}
connectedCallback = callback;
},
disconnect() {
clearTimeout(timeout);
}
};
}
import Toastify from 'toastify-js';
import 'toastify-js/src/toastify.css';
export function showNotification(message, theme) {
Toastify({
text: message,
duration: 2000,
gravity: 'top',
position: 'right',
style: {
background: theme === 'dark' ? 'black' : 'white',
color: theme === 'dark' ? 'white' : 'black',
},
}).showToast();
}
label { display: block; margin-top: 10px; }
Ефект, для якого roomId
було встановлено на "travel"
(тобто він підключився до кімнати "travel"
), покаже сповіщення для "travel"
. Для ефекту roomId
, для якого встановлено значення "music"
(тобто він підключився до кімнати "music"
), буде показано сповіщення для "music"
. Іншими словами, connectedRoomId
походить від вашого ефекту (який є реактивним), тоді як тема
завжди використовує останнє значення.
Щоб вирішити додаткову проблему, збережіть ідентифікатор таймауту сповіщення та очистіть його у функції очищення ефекту:
{
"dependencies": {
"react": "experimental",
"react-dom": "experimental",
"react-scripts": "latest",
"toastify-js": "1.12.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}
import { useState, useEffect } from 'react';
import { experimental_useEffectEvent as 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(connectedRoomId => {
showNotification('Welcome to ' + connectedRoomId, theme);
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
let notificationTimeoutId;
connection.on('connected', () => {
notificationTimeoutId = setTimeout(() => {
onConnected(roomId);
}, 2000);
});
connection.connect();
return () => {
connection.disconnect();
if (notificationTimeoutId !== undefined) {
clearTimeout(notificationTimeoutId);
}
};
}, [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'}
/>
</>
);
}
export function createConnection(serverUrl, roomId) {
// A real implementation would actually connect to the server
let connectedCallback;
let timeout;
return {
connect() {
timeout = setTimeout(() => {
if (connectedCallback) {
connectedCallback();
}
}, 100);
},
on(event, callback) {
if (connectedCallback) {
throw Error('Cannot add the handler twice.');
}
if (event !== 'connected') {
throw Error('Only "connected" event is supported.');
}
connectedCallback = callback;
},
disconnect() {
clearTimeout(timeout);
}
};
}
import Toastify from 'toastify-js';
import 'toastify-js/src/toastify.css';
export function showNotification(message, theme) {
Toastify({
text: message,
duration: 2000,
gravity: 'top',
position: 'right',
style: {
background: theme === 'dark' ? 'black' : 'white',
color: theme === 'dark' ? 'white' : 'black',
},
}).showToast();
}
label { display: block; margin-top: 10px; }
Це гарантує, що вже заплановані (але ще не відображені) сповіщення будуть скасовані при зміні кімнати.