Відокремлення подій від ефектів

Обробники подій запускаються повторно лише тоді, коли ви виконуєте ту саму взаємодію знову. На відміну від обробників подій, ефекти пересинхронізуються, якщо якесь значення, яке вони зчитують, наприклад, проп або змінна стану, відрізняється від того, що було під час останнього рендерингу. Іноді вам також буде потрібен мікс обох типів поведінки: ефект, який повторно запускається у відповідь на деякі значення, але не запускається на інші. На цій сторінці ви дізнаєтеся, як це зробити.

  • Як вибрати між обробником події та ефектом
  • Чому ефекти є реактивними, а обробники подій - ні
  • Що робити, якщо ви хочете, щоб частина коду ефекту не була реактивною
  • Що таке події ефектів і як їх витягувати з ефектів
  • Як прочитати останні пропси та стан з ефектів за допомогою подій ефектів

Вибір між обробниками подій та ефектами

Спочатку нагадаємо різницю між обробниками подій та ефектами.

Уявіть, що ви розробляєте компонент чату. Ваші вимоги виглядають наступним чином:

  1. Ваш компонент має автоматично підключатися до обраної кімнати чату.
  2. Коли ви натискаєте кнопку "Відправити", вона має відправити повідомлення в чат.

Припустимо, ви вже реалізували код для них, але не знаєте, куди його вставити. Чи варто використовувати обробники подій або ефекти? Щоразу, коли вам потрібно відповісти на це питання, подумайте чому код повинен працювати.

Обробники подій запускаються у відповідь на певні взаємодії

З точки зору користувача, відправлення повідомлення має відбутися тому що було натиснуто конкретну кнопку "Відправити". Користувач дуже засмутиться, якщо ви надішлете його повідомлення у будь-який інший час або з будь-якої іншої причини. Ось чому надсилання повідомлення має бути обробником події. Обробники подій дозволяють вам обробляти специфічні взаємодії:

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; }

Реактивні значення та реактивна логіка

Інтуїтивно можна сказати, що обробники подій завжди запускаються "вручну", наприклад, натисканням кнопки. Натомість ефекти є "автоматичними": вони запускаються і повторюються стільки разів, скільки потрібно для синхронізації.

Існує більш точний спосіб подумати про це.

Пропси, стан та змінні, оголошені у тілі вашого компонента, називаються реактивними значеннями</CodeStep> . У цьому прикладі <code>serverUrl не є реактивним значенням, але roomId і message є. Вони беруть участь у потоці даних рендерингу:

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  // ...
}

Такі реактивні значення можуть змінюватися внаслідок повторного рендерингу. Наприклад, користувач може відредагувати повідомлення або обрати інше roomId у випадаючому списку. Обробники подій та ефекти реагують на зміни по-різному:

  • Логіка всередині обробників подій не є реактивною. Вона не запуститься знову, доки користувач не виконає ту саму взаємодію (наприклад, натискання) знову. Обробники подій можуть читати реактивні значення, не "реагуючи" на їхні зміни.
  • Логіка всередині ефектів є реактивною. Якщо ваш ефект читає реактивне значення, ви повинні вказати його як залежність. Тоді, якщо повторний рендеринг призведе до зміни цього значення, React перезапустить логіку вашого ефекту з новим значенням.

Повернімося до попереднього прикладу, щоб проілюструвати цю різницю.

Логіка всередині обробників подій не є реактивною

Погляньте на цей рядок коду. Ця логіка має бути реактивною чи ні?

// ...
    sendMessage(message);
    // ...

З точки зору користувача, зміна у повідомленні не означає, що він хоче надіслати повідомлення. Це лише означає, що користувач вводить текст. Іншими словами, логіка, яка надсилає повідомлення, не повинна бути реактивною. Вона не повинна запускатися знову тільки тому, що значення реактивне</CodeStep> змінився. Тому він має бути в обробнику події:</p> <pre><code data-meta="{2}" class="language-js">function handleSendClick() { надіслати повідомлення (message); }

Обробники подій не є реактивними, тому sendMessage(message) буде запущено лише тоді, коли користувач натисне кнопку Надіслати.

Логіка всередині Effects є реактивною

А тепер повернімося до цих рядків:

// ...
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    // ...

З точки зору користувача, зміна на roomId означає, що він хоче з'єднатися з іншою кімнатою. Іншими словами, логіка з'єднання з кімнатою повинна бути реактивною. Ви хочете, щоб ці рядки коду "не відставали" від реактивного значення </CodeStep> , і запустить її знову, якщо значення зміниться. Ось чому він має бути ефектом:</p> <pre><code data-meta="{2-3}" class="language-js">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();
    // ...

Втім, тема є реактивною величиною (вона може змінюватися в результаті повторного рендерингу), а кожна реактивна величина, яку зчитує Ефект, має бути оголошена як його залежність. Тепер вам слід вказати тему як залежність вашого ефекту:

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 не пересинхронізував ефект після зміни canMovehandleMove разом з ним). Оскільки 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; }

Це гарантує, що вже заплановані (але ще не відображені) сповіщення будуть скасовані при зміні кімнати.