Життєвий цикл реактивних ефектів

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

  • Як життєвий цикл ефекту відрізняється від життєвого циклу компонента
  • Як думати про кожен окремий Ефект окремо
  • Коли ваш ефект потребує пересинхронізації і чому
  • Як визначаються залежності вашого ефекту
  • Що означає, коли значення є реактивним
  • .
  • Що означає порожній масив залежностей
  • Як React перевіряє коректність ваших залежностей за допомогою лінтеру
  • Що робити, якщо ви не згодні з лінтером

Життєвий цикл ефекту

Кожен React-компонент проходить один і той самий життєвий цикл:

  • Компонент монтується коли його додано на екран.
  • Компонент оновлює , коли отримує нові пропси або стан, зазвичай у відповідь на взаємодію.
  • Компонент демонтується коли його вилучено з екрана.

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

Щоб проілюструвати цей момент, розглянемо цей ефект підключення вашого компонента до сервера чату:

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

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [roomId]);
  // ...
}

В тілі вашого ефекту вказано, як почати синхронізацію:

// ...
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
    // ...

Функція очищення, яку повертає ваш ефект, визначає, як зупинити синхронізацію:

// ...
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
    // ...

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

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

Деякі ефекти взагалі не повертають функцію очищення. Частіше за все, ви захочете повернути її - але якщо ви цього не зробите, React поводитиметься так, ніби ви повернули порожню функцію очищення.

Чому синхронізація може відбуватися більше одного разу

Уявіть, що цей ChatRoom компонент отримує проп roomId, який користувач вибирає у випадаючому списку. Скажімо, спочатку користувач обирає кімнату "general" як roomId. Ваша програма відображає кімнату чату "general":

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

function ChatRoom({ roomId /* "general" */ }) {
  // ...
  return <h1>Welcome to the {roomId} room!</h1>;
}

Після відображення інтерфейсу React запустить ваш ефект, щоб почати синхронізацію. Він підключиться до "general"кімнати:

function ChatRoom({ roomId /* "general" */ }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId); // Connects to the "general" room
    connection.connect();
    return () => {
      connection.disconnect(); // Disconnects from the "general" room
    };
  }, [roomId]);
  // ...

Поки що все добре.

Пізніше користувач вибирає іншу кімнату у випадаючому списку (наприклад, "travel"). Спочатку React оновить інтерфейс користувача:

function ChatRoom({ roomId /* "travel" */ }) {
  // ...
  return <h1>Welcome to the {roomId} room!</h1>;
}

Подумайте, що має відбутися далі. Користувач бачить, що "travel" є вибраною кімнатою чату у інтерфейсі. Однак ефект, який було запущено минулого разу, все ще пов'язаний з кімнатою "general". Проп roomId змінився, тому те, що ваш ефект зробив тоді (підключення до кімнати "general"), більше не відповідає інтерфейсу користувача.

На цьому етапі ви хочете, щоб React робив дві речі:

  1. Припинити синхронізацію зі старим roomId (від'єднатися від кімнати "general")
  2. Почати синхронізацію з новим roomId (підключитися до кімнати "travel")

На щастя, ви вже навчили React робити обидві ці речі! Тіло вашого ефекту визначає, як почати синхронізацію, а функція очищення визначає, як зупинити синхронізацію. Все, що потрібно зробити React - це викликати їх у правильному порядку, з правильними пропсами та станом. Давайте подивимося, як саме це відбувається.

Як React ресинхронізує ваш ефект

Нагадаємо, що ваш компонент ChatRoom отримав нове значення для свого пропу roomId. Раніше це було "general", а тепер це "travel". React має повторно синхронізувати ваш ефект, щоб перепідключити вас до іншої кімнати.

Щоб зупинити синхронізацію, React викличе функцію очищення, яку ваш ефект повернув після підключення до кімнати "general". Оскільки roomId був "general", функція очищення від'єднається від кімнати "general":

function ChatRoom({ roomId /* "general" */ }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId); // Connects to the "general" room
    connection.connect();
    return () => {
      connection.disconnect(); // Disconnects from the "general" room
    };
    // ...

Тоді React запустить ефект, який ви надали під час цього рендерингу. Цього разу roomId є "travel", тому він почне синхронізацію з чатом "travel" (доки не буде викликано його функцію очищення):

function ChatRoom({ roomId /* "travel" */ }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId); // Connects to the "travel" room
    connection.connect();
    // ...

Завдяки цьому ви тепер підключені до тієї ж кімнати, яку користувач вибрав у інтерфейсі. Катастрофу попереджено!

Щоразу після того, як ваш компонент перезавантажиться з іншим roomId, ваш ефект буде повторно синхронізуватися. Наприклад, скажімо, користувач змінює roomId з "travel" на "music". React знову припинить синхронізацію вашого ефекту, викликавши свою функцію очищення (від'єднавши вас від кімнати "travel"). Потім він знову почне синхронізацію, запустивши своє тіло з новим пропсом roomId (підключивши вас до кімнати "music").

Нарешті, коли користувач переходить на інший екран, ChatRoom демонтується. Тепер взагалі не потрібно залишатися на зв'язку. React востаннє зупинить синхронізацію вашого ефекту і від'єднає вас від чату "music".

Мислення з точки зору ефекту

Давайте підсумуємо все, що відбулося з точки зору компонента ChatRoom:

  1. ChatRoom змонтовано з roomId, встановленим на "general"
  2. ChatRoom оновлено з roomId, встановленим як "travel"
  3. ChatRoom оновлено з roomId, встановленим як "music"
  4. ChatRoom демонтовано

Протягом кожного з цих етапів життєвого циклу компонента ваш ефект робив різні речі:

  1. Ваш ефект підключено до кімнати "general"
  2. Ваш ефект від'єднано від кімнати "general" та підключено до кімнати "travel"
  3. .
  4. Ваш ефект від'єднано від кімнати "travel" та підключено до кімнати "music"
  5. Ваш ефект від'єднано від "music" кімнати

Тепер давайте подумаємо про те, що сталося з точки зору самого ефекту:

useEffect(() => {
    // Your Effect connected to the room specified with roomId...
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      // ...until it disconnected
      connection.disconnect();
    };
  }, [roomId]);

Структура цього коду може надихнути вас побачити те, що сталося, як послідовність періодів часу, що не перекриваються:

  1. Ваш ефект підключено до кімнати "general" (поки її не відключили)
  2. Ваш ефект було підключено до кімнати "travel" (до моменту її відключення)
  3. Ваш ефект, підключений до кімнати "music" (поки його не відключили)

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

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

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

Як React перевіряє, чи може ваш ефект повторно синхронізуватися

Ось живий приклад, з яким ви можете погратися. Натисніть "Відкрити чат", щоб змонтувати компонент ChatRoom:

import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

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

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);
  return <h1>Welcome to the {roomId} room!</h1>;
}

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 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 { display: block; margin-bottom: 20px; }
button { margin-left: 10px; }

Зверніть увагу, що коли компонент монтується вперше, ви бачите три журнали:

  1. ✅ Connecting to "general" room at https://localhost:1234... (лише для розробки)
  2. ❌ Disconnected from "general" room at https://localhost:1234. (development-only)
  3. ✅ Connecting to "general" room at https://localhost:1234...

Перші два логи призначені лише для розробки. Під час розробки React завжди повторно монтує кожен компонент один раз.

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

Основна причина, через яку ваш ефект пересинхронізується на практиці, - це зміна деяких даних, які він використовує. У наведеній вище пісочниці змініть вибрану кімнату чату. Зверніть увагу, як при зміні roomId ваш ефект повторно синхронізується.

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

Як React дізнається, що потрібно пересинхронізувати ефект

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

function ChatRoom({ roomId }) { // The roomId prop may change over time
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId); // This Effect reads roomId 
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [roomId]); // So you tell React that this Effect "depends on" roomId
  // ...

Ось як це працює:

  1. Ви знали, що roomId - це проп, а це означає, що він може змінюватися з часом.
  2. Ви знали, що ваш ефект читається як roomId (тобто його логіка залежить від значення, яке може змінитися пізніше).
  3. Саме тому ви вказали його як залежність ефекту (щоб він пересинхронізувався при зміні roomId).

Кожного разу після повторного рендерингу вашого компонента React буде переглядати масив залежностей, який ви передали. Якщо будь-яке зі значень у масиві відрізняється від значення у тому ж місці, яке ви передали під час попереднього рендерингу, React пересинхронізує ваш ефект.

Наприклад, якщо ви передали ["general"] під час початкового рендерингу, а потім передали ["travel"] під час наступного рендерингу, React порівняє "general" та "travel". Це різні значення (порівняно з Object.is), тому React пересинхронізує ваш ефект. З іншого боку, якщо ваш компонент відрендериться повторно, але roomId не зміниться, ваш ефект залишиться підключеним до тієї ж кімнати.

Кожен ефект являє собою окремий процес синхронізації

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

function ChatRoom({ roomId }) {
  useEffect(() => {
    logVisit(roomId);
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [roomId]);
  // ...
}

Але уявіть, що пізніше ви додасте до цього ефекту іншу залежність, яка потребує повторного встановлення з'єднання. Якщо цей ефект повторно синхронізується, він також викличе logVisit(roomId) для тієї самої кімнати, чого ви не планували. Реєстрація візиту є окремим процесом від встановлення з'єднання. Запишіть їх як два окремі ефекти:

function ChatRoom({ roomId }) {
  useEffect(() => {
    logVisit(roomId);
  }, [roomId]);

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    // ...
  }, [roomId]);
  // ...
}

Кожен ефект у вашому коді повинен представляти окремий і незалежний процес синхронізації.

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

Ефекти "реагують" на реактивні значення

Ваш ефект зчитує дві змінні (serverUrl та roomId), але ви вказали лише roomId як залежну:

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

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [roomId]);
  // ...
}

Чому serverUrl не потрібно встановлювати залежність?

Це тому, що serverUrl ніколи не змінюється внаслідок повторного рендерингу. Він завжди однаковий, незалежно від того, скільки разів компонент повторно рендериться і чому. Оскільки serverUrl ніколи не змінюється, немає сенсу вказувати його як залежність. Зрештою, залежності роблять щось тільки тоді, коли вони змінюються з часом!

З іншого боку, roomId може бути інакшим при повторному рендері. Пропси, стан та інші значення, оголошені всередині компонента, є реактивними, оскільки вони обчислюються під час рендерингу та беруть участь у потоці даних React.

Якби serverUrl була змінною стану, вона була б реактивною. Реактивні значення мають бути включені у залежності:

function ChatRoom({ roomId }) { // Props change over time
  const [serverUrl, setServerUrl] = useState('https://localhost:1234'); // State may change over time

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId); // Your Effect reads props and state
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [roomId, serverUrl]); // So you tell React that this Effect "depends on" on props and state
  // ...
}

Додавши serverUrl як залежність, ви забезпечите повторну синхронізацію ефекту після його зміни.

Спробуйте змінити обрану кімнату чату або відредагувати URL-адресу сервера в цій пісочниці:

import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, serverUrl]);

  return (
    <>
      <label>
        Server URL:{' '}
        <input
          value={serverUrl}
          onChange={e => setServerUrl(e.target.value)}
        />
      </label>
      <h1>Welcome to the {roomId} room!</h1>
    </>
  );
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  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>
      <hr />
      <ChatRoom roomId={roomId} />
    </>
  );
}
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 { display: block; margin-bottom: 20px; }
button { margin-left: 10px; }

Щоразу, коли ви змінюєте реактивне значення на кшталт roomId або serverUrl, ефект перепідключається до сервера чату.

Що означає ефект з порожніми залежностями

Що станеться, якщо перемістити обидва serverUrl та roomId за межі компонента?

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

function ChatRoom() {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, []); // ✅ All dependencies declared
  // ...
}

Тепер код ефекту не використовує ніяких реактивних значень, тому його залежності можуть бути порожніми ([]).

З точки зору компонента, порожній масив залежностей [] означає, що цей ефект підключається до чату лише тоді, коли компонент монтується, і відключається лише тоді, коли компонент демонтується. (Майте на увазі, що React все одно повторно синхронізує його додатковий час під час розробки для стрес-тестування вашої логіки.)

import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

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

function ChatRoom() {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, []);
  return <h1>Welcome to the {roomId} room!</h1>;
}

export default function App() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => setShow(!show)}>
        {show ? 'Close chat' : 'Open chat'}
      </button>
      {show && <hr />}
      {show && <ChatRoom />}
    </>
  );
}
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 { display: block; margin-bottom: 20px; }
button { margin-left: 10px; }

Втім, якщо ви думаєте з точки зору ефекту, вам взагалі не потрібно думати про монтування та демонтування. Важливо те, що ви визначили, як саме ваш ефект має запускати та зупиняти синхронізацію. Сьогодні він не має реактивних залежностей. Але якщо ви захочете, щоб користувач з часом змінив roomId або serverUrl (і вони стануть реактивними), код вашого ефекту не зміниться. Вам потрібно буде лише додати їх до залежностей.

Усі змінні, оголошені у тілі компонента, є реактивними

Пропси і стан - не єдині реактивні значення. Значення, які ви обчислюєте на їх основі, також є реактивними. Якщо пропси або стан зміняться, ваш компонент відрендериться повторно, і значення, що обчислюються на їх основі, також зміняться. Ось чому всі змінні з тіла компонента, які використовуються ефектом, мають бути у списку залежностей ефекту.

Скажімо, користувач може вибрати сервер чату у випадаючому списку, але він також може налаштувати сервер за замовчуванням у налаштуваннях. Припустимо, ви вже помістили стан налаштувань у контекст, тому ви читаєте налаштування з цього контексту. Тепер ви обчислюєте serverUrl на основі вибраного сервера з пропсів і сервера за замовчуванням:

function ChatRoom({ roomId, selectedServerUrl }) { // roomId is reactive
  const settings = useContext(SettingsContext); // settings is reactive
  const serverUrl = selectedServerUrl ?? settings.defaultServerUrl; // serverUrl is reactive
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId); // Your Effect reads roomId and serverUrl
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [roomId, serverUrl]); // So it needs to re-synchronize when either of them changes!
  // ...
}

У цьому прикладі serverUrl не є пропсом або змінною стану. Це звичайна змінна, яку ви обчислюєте під час рендерингу. Але вона обчислюється під час рендерингу, тому може змінитися внаслідок повторного рендерингу. Ось чому вона є реактивною.

Усі значення всередині компонента (включаючи пропси, стан та змінні у тілі вашого компонента) є реактивними. Будь-яке реактивне значення може змінитися при повторному рендерингу, тому вам потрібно включити реактивні значення як залежності ефекту.

Іншими словами, ефекти "реагують" на всі значення з тіла компонента.

Чи можуть глобальні або змінювані значення бути залежностями?

Змінювані значення (включно з глобальними змінними) не реагують.

Змінне значення на кшталт location.pathname не може бути залежністю. Воно змінне, тому може змінитися в будь-який час поза потоком даних рендерингу React. Її зміна не призведе до перезавантаження вашого компонента. Тому, навіть якщо ви вказали його в залежностях, React не знатиме , що потрібно пересинхронізувати ефект при його зміні. Це також порушує правила React, оскільки читання змінних даних під час рендерингу (коли ви обчислюєте залежності) порушує чистоту рендерингу. Натомість, вам слід читати і підписуватися на зовнішнє змінне значення за допомогою useSyncExternalStore.

Змінне значення на кшталт ref.current або те, що ви з нього читаєте, також не може бути залежністю. Об'єкт ref, що повертається useRef, сам по собі може бути залежністю, але його властивість current є навмисно змінною. Вона дозволяє вам відстежувати щось, не викликаючи повторного рендерингу. Але оскільки її зміна не викликає повторного рендерингу, вона не є реактивним значенням, і React не знатиме, що потрібно перезапустити ваш ефект, коли вона зміниться.

Як ви дізнаєтеся нижче на цій сторінці, лінтер перевірятиме ці проблеми автоматично.

React перевіряє, що ви вказали кожне реактивне значення як залежність

Якщо ваш лінтер налаштовано на React, він перевірятиме, чи кожне реактивне значення, що використовується кодом вашого ефекту, оголошено як його залежність. Наприклад, це помилка ворсу, оскільки і roomId, і serverUrl є реактивними:

import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

function ChatRoom({ roomId }) { // roomId is reactive
  const [serverUrl, setServerUrl] = useState('https://localhost:1234'); // serverUrl is reactive

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, []); // <-- Something's wrong here!

  return (
    <>
      <label>
        Server URL:{' '}
        <input
          value={serverUrl}
          onChange={e => setServerUrl(e.target.value)}
        />
      </label>
      <h1>Welcome to the {roomId} room!</h1>
    </>
  );
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  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>
      <hr />
      <ChatRoom roomId={roomId} />
    </>
  );
}
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 { display: block; margin-bottom: 20px; }
button { margin-left: 10px; }

Це може виглядати як помилка React, але насправді React вказує на проблему у вашому коді. І roomId, і serverUrl можуть змінюватися з часом, але ви забуваєте повторно синхронізувати ваш ефект, коли вони змінюються. Ви залишатиметеся підключеними до початкових roomId та serverUrl навіть після того, як користувач вибере інші значення в інтерфейсі користувача.

Щоб виправити проблему, скористайтеся порадою лінтера і вкажіть roomId та serverUrl як залежності вашого ефекту:

function ChatRoom({ roomId }) { // roomId is reactive
  const [serverUrl, setServerUrl] = useState('https://localhost:1234'); // serverUrl is reactive
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [serverUrl, roomId]); // ✅ All dependencies declared
  // ...
}

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

У деяких випадках React знає , що значення ніколи не змінюється, навіть якщо воно оголошене всередині компонента. Наприклад, set функція, повернута з useState, та об'єкт ref, повернутий useRef, є стабільними - вони гарантовано не зміняться при повторному рендерингу. Стабільні значення не реагують на зміни, тому ви можете не додавати їх до списку. Їх включення дозволено: вони не зміняться, тому це не має значення.

Що робити, коли ви не хочете повторно синхронізувати

У попередньому прикладі ви виправили помилку lint, додавши roomId та serverUrl як залежності.

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

const serverUrl = 'https://localhost:1234'; // serverUrl is not reactive
const roomId = 'general'; // roomId is not reactive

function ChatRoom() {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, []); // ✅ All dependencies declared
  // ...
}

Ви також можете перемістити їх всередину ефекту. Вони не обчислюються під час рендерингу, тому вони не реагують:

function ChatRoom() {
  useEffect(() => {
    const serverUrl = 'https://localhost:1234'; // serverUrl is not reactive
    const roomId = 'general'; // roomId is not reactive
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, []); // ✅ All dependencies declared
  // ...
}

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

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

  • Переконайтеся, що ваш ефект є незалежним процесом синхронізації.Якщо ваш ефект нічого не синхронізує, він може бути непотрібним.Якщо він синхронізує кілька незалежних речей, розділіть його.

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

  • Уникайте використання об'єктів та функцій як залежностей. Якщо ви створюєте об'єкти та функції під час зображування, а потім зчитуєте їх з ефекту, вони будуть різними на кожному зображуванні. Це призведе до повторної синхронізації ефекту щоразу. Довідка про видалення непотрібних залежностей з ефектів.

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

Якщо у вас є наявна кодова база, ви можете мати деякі ефекти, які пригнічують лінтер, наприклад:

useEffect(() => {
  // ...
  // 🔴 Avoid suppressing the linter like this:
  // eslint-ignore-next-line react-hooks/exhaustive-deps
}, []);

На наступних сторінках ви дізнаєтесь, як виправити цей код без порушення правил. Це завжди варто виправляти!

  • Компоненти можна монтувати, оновлювати та демонтувати
  • Кожен ефект має окремий життєвий цикл від навколишнього компонента.
  • Кожен ефект описує окремий процес синхронізації, який можна запустити і зупинити.
  • Під час запису та читання ефектів думайте з точки зору кожного окремого ефекту (як запустити та зупинити синхронізацію), а не з точки зору компонента (як він монтується, оновлюється або демонтується).
  • Значення, оголошені у тілі компонента, є "реактивними".
  • Реактивні значення слід повторно синхронізувати з ефектом, оскільки вони можуть змінюватися з часом.
  • Лінтер перевіряє, чи всі реактивні значення, що використовуються всередині ефекту, вказано як залежності.
  • Усі помилки, позначені лінтером, є легітимними. Завжди є спосіб виправити код, щоб не порушувати правила.

Виправити перепідключення при кожному натисканні клавіші

У цьому прикладі компонент ChatRoom підключається до чату, коли компонент монтується, відключається, коли він демонтується, і знову підключається, коли ви вибираєте інший чат. Така поведінка є правильною, тому вам слід залишити її робочою.

Однак є проблема. Щоразу, коли ви вводите текст у вікні для введення повідомлення внизу, ChatRoom також перепідключається до чату. (Ви можете помітити це, очистивши консоль і ввівши дані). Виправте проблему, щоб цього не сталося.

Можливо, вам доведеться додати масив залежностей для цього ефекту. Які залежності там мають бути?

import { useState, useEffect } from 'react';
import { createConnection } 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();
  });

  return (
    <>
      <h1>Welcome to the {roomId} room!</h1>
      <input
        value={message}
        onChange={e => setMessage(e.target.value)}
      />
    </>
  );
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  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>
      <hr />
      <ChatRoom roomId={roomId} />
    </>
  );
}
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 { display: block; margin-bottom: 20px; }
button { margin-left: 10px; }

Цей ефект взагалі не мав масиву залежностей, тому він пересинхронізувався після кожного повторного рендерингу. По-перше, додайте масив залежностей. Потім переконайтеся, що кожне реактивне значення, яке використовується ефектом, вказано в масиві. Наприклад, roomId є реактивним (тому що це проп), тому його слід включити до масиву. Це гарантує, що коли користувач вибере іншу кімнату, чат перепідключиться. З іншого боку, serverUrl визначено за межами компонента. Тому його не потрібно додавати до масиву.

import { useState, useEffect } from 'react';
import { createConnection } 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]);

  return (
    <>
      <h1>Welcome to the {roomId} room!</h1>
      <input
        value={message}
        onChange={e => setMessage(e.target.value)}
      />
    </>
  );
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  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>
      <hr />
      <ChatRoom roomId={roomId} />
    </>
  );
}
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 { display: block; margin-bottom: 20px; }
button { margin-left: 10px; }

Увімкнення та вимкнення синхронізації

У цьому прикладі ефект підписується на подію вікна pointermove для переміщення рожевої точки на екрані. Спробуйте навести вказівник миші на область попереднього перегляду (або торкніться екрана, якщо ви користуєтеся мобільним пристроєм) і подивіться, як рожева точка слідує за вашим рухом.

Тут також є прапорець. Встановлення прапорця перемикає змінну стану canMove, але ця змінна стану ніде у коді не використовується. Ваше завдання - змінити код так, щоб коли canMove має значення false (прапорець знято), точка переставала рухатися. Після того, як ви знову увімкнете прапорець (і встановите canMove як true), точка знову почне рухатися. Іншими словами, чи може точка рухатися, чи ні, має бути синхронізовано з тим, чи встановлено прапорець.

Ви не можете оголосити Ефект умовно. Однак код всередині ефекту може використовувати умови!

import { useState, useEffect } from 'react';

export default function App() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [canMove, setCanMove] = useState(true);

  useEffect(() => {
    function handleMove(e) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
    window.addEventListener('pointermove', handleMove);
    return () => window.removeEventListener('pointermove', handleMove);
  }, []);

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

Одне з рішень - обернути виклик setPosition в умову if (canMove) { ... }:

import { useState, useEffect } from 'react';

export default function App() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [canMove, setCanMove] = useState(true);

  useEffect(() => {
    function handleMove(e) {
      if (canMove) {
        setPosition({ x: e.clientX, y: e.clientY });
      }
    }
    window.addEventListener('pointermove', handleMove);
    return () => window.removeEventListener('pointermove', handleMove);
  }, [canMove]);

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

Альтернативно, ви можете обгорнути логіку підписки на подію в умову if (canMove) { ... }:

import { useState, useEffect } from 'react';

export default function App() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [canMove, setCanMove] = useState(true);

  useEffect(() => {
    function handleMove(e) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
    if (canMove) {
      window.addEventListener('pointermove', handleMove);
      return () => window.removeEventListener('pointermove', handleMove);
    }
  }, [canMove]);

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

В обох цих випадках canMove є реактивною змінною, яку ви читаєте всередині ефекту. Саме тому її потрібно вказати у списку залежностей ефекту. Це гарантує, що ефект повторно синхронізується після кожної зміни його значення.

Дослідити ваду застарілих значень

У цьому прикладі рожева крапка має рухатися, коли прапорець увімкнено, і припиняти рух, коли його вимкнено. Логіку для цього вже реалізовано: обробник події handleMove перевіряє змінну стану canMove.

Однак, чомусь змінна стану canMove всередині handleMove виявляється "несвіжою": вона завжди true, навіть після зняття галочки. Як таке можливо? Знайдіть помилку в коді і виправте її.

Якщо ви бачите, що правило linter пригнічено, зніміть пригнічення! Зазвичай помилки трапляються саме там.

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 з початкового відображення бачитиме це значення завжди.

Якщо ви ніколи не пригнічуватимете лінтер, ви ніколи не побачите проблем із застарілими значеннями. Існує кілька різних способів усунення цієї проблеми, але завжди слід починати з вилучення пригнічення лінтеру. Потім змініть код, щоб виправити помилку лінтеру.

Ви можете змінити залежності Effect на [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);
  });

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

Це рішення працює, але воно не ідеальне. Якщо ви помістите console.log('Resubscribing') всередину ефекту, ви помітите, що він перепідписується після кожного повторного рендерингу. Повторна підписка відбувається швидко, але все ж таки було б непогано не робити це так часто.

Кращим виправленням буде переміщення функції handleMove всередину Ефекту. Тоді handleMove не буде реактивним значенням, і ваш ефект не залежатиме від функції. Натомість він залежатиме від canMove, яке ваш код тепер читає зсередини ефекту. Це відповідає поведінці, яку ви хотіли, оскільки ваш ефект тепер буде синхронізовано зі значенням canMove:

import { useState, useEffect } from 'react';

export default function App() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [canMove, setCanMove] = useState(true);

  useEffect(() => {
    function handleMove(e) {
      if (canMove) {
        setPosition({ x: e.clientX, y: e.clientY });
      }
    }

    window.addEventListener('pointermove', handleMove);
    return () => window.removeEventListener('pointermove', handleMove);
  }, [canMove]);

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

Спробуйте додати console.log('Resubscribing') у тіло ефекту і помітьте, що тепер він повторно підписується лише тоді, коли ви перемикаєте прапорець (canMove змінюється) або редагуєте код. Це робить його кращим за попередній підхід, який завжди повторно підписувався.

Більш загальний підхід до цього типу проблем ви знайдете у розділі Відокремлення подій від ефектів.

Виправлено перемикач підключення

У цьому прикладі чат-сервіс у chat.js використовує два різних API: createEncryptedConnection та createUnencryptedConnection. Кореневий компонент App дозволяє користувачеві вибрати, використовувати шифрування чи ні, а потім передає відповідний метод API дочірньому компоненту ChatRoom у вигляді пропу createConnection.

Зверніть увагу, що спочатку у журналах консолі буде вказано, що з'єднання не зашифровано. Спробуйте увімкнути прапорець: нічого не станеться. Однак, якщо після цього ви зміните обрану кімнату, то чат перепідключиться і увімкне шифрування (як ви побачите з консольних повідомлень). Це баг. Виправте ваду так, щоб перемикання прапорців та призводило до перепідключення чату.

Вимкнення лінтеру завжди викликає підозру. Чи може це бути вадою?

import { useState } from 'react';
import ChatRoom from './ChatRoom.js';
import {
  createEncryptedConnection,
  createUnencryptedConnection,
} from './chat.js';

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [isEncrypted, setIsEncrypted] = 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={isEncrypted}
          onChange={e => setIsEncrypted(e.target.checked)}
        />
        Enable encryption
      </label>
      <hr />
      <ChatRoom
        roomId={roomId}
        createConnection={isEncrypted ?
          createEncryptedConnection :
          createUnencryptedConnection
        }
      />
    </>
  );
}
import { useState, useEffect } from 'react';

export default function ChatRoom({ roomId, createConnection }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
    return () => connection.disconnect();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [roomId]);

  return <h1>Welcome to the {roomId} room!</h1>;
}
export function createEncryptedConnection(roomId) {
  // A real implementation would actually connect to the server
  return {
    connect() {
      console.log('✅ 🔐 Connecting to "' + roomId + '... (encrypted)');
    },
    disconnect() {
      console.log('❌ 🔐 Disconnected from "' + roomId + '" room (encrypted)');
    }
  };
}

export function createUnencryptedConnection(roomId) {
  // A real implementation would actually connect to the server
  return {
    connect() {
      console.log('✅ Connecting to "' + roomId + '... (unencrypted)');
    },
    disconnect() {
      console.log('❌ Disconnected from "' + roomId + '" room (unencrypted)');
    }
  };
}
label { display: block; margin-bottom: 10px; }

Якщо ви вимкнете придушення лінтерів, ви побачите помилку лінтерів. Проблема у тому, що createConnection є пропсом, тобто реактивним значенням. Воно може змінюватися з часом! (І справді, так і має бути - коли користувач ставить галочку, батьківський компонент передає інше значення пропсу createConnection). Ось чому це має бути залежність. Включіть її до списку для виправлення проблеми:

import { useState } from 'react';
import ChatRoom from './ChatRoom.js';
import {
  createEncryptedConnection,
  createUnencryptedConnection,
} from './chat.js';

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [isEncrypted, setIsEncrypted] = 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={isEncrypted}
          onChange={e => setIsEncrypted(e.target.checked)}
        />
        Enable encryption
      </label>
      <hr />
      <ChatRoom
        roomId={roomId}
        createConnection={isEncrypted ?
          createEncryptedConnection :
          createUnencryptedConnection
        }
      />
    </>
  );
}
import { useState, useEffect } from 'react';

export default function ChatRoom({ roomId, createConnection }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, createConnection]);

  return <h1>Welcome to the {roomId} room!</h1>;
}
export function createEncryptedConnection(roomId) {
  // A real implementation would actually connect to the server
  return {
    connect() {
      console.log('✅ 🔐 Connecting to "' + roomId + '... (encrypted)');
    },
    disconnect() {
      console.log('❌ 🔐 Disconnected from "' + roomId + '" room (encrypted)');
    }
  };
}

export function createUnencryptedConnection(roomId) {
  // A real implementation would actually connect to the server
  return {
    connect() {
      console.log('✅ Connecting to "' + roomId + '... (unencrypted)');
    },
    disconnect() {
      console.log('❌ Disconnected from "' + roomId + '" room (unencrypted)');
    }
  };
}
label { display: block; margin-bottom: 10px; }

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

import { useState } from 'react';
import ChatRoom from './ChatRoom.js';

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [isEncrypted, setIsEncrypted] = 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={isEncrypted}
          onChange={e => setIsEncrypted(e.target.checked)}
        />
        Enable encryption
      </label>
      <hr />
      <ChatRoom
        roomId={roomId}
        isEncrypted={isEncrypted}
      />
    </>
  );
}
import { useState, useEffect } from 'react';
import {
  createEncryptedConnection,
  createUnencryptedConnection,
} from './chat.js';

export default function ChatRoom({ roomId, isEncrypted }) {
  useEffect(() => {
    const createConnection = isEncrypted ?
      createEncryptedConnection :
      createUnencryptedConnection;
    const connection = createConnection(roomId);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, isEncrypted]);

  return <h1>Welcome to the {roomId} room!</h1>;
}
export function createEncryptedConnection(roomId) {
  // A real implementation would actually connect to the server
  return {
    connect() {
      console.log('✅ 🔐 Connecting to "' + roomId + '... (encrypted)');
    },
    disconnect() {
      console.log('❌ 🔐 Disconnected from "' + roomId + '" room (encrypted)');
    }
  };
}

export function createUnencryptedConnection(roomId) {
  // A real implementation would actually connect to the server
  return {
    connect() {
      console.log('✅ Connecting to "' + roomId + '... (unencrypted)');
    },
    disconnect() {
      console.log('❌ Disconnected from "' + roomId + '" room (unencrypted)');
    }
  };
}
label { display: block; margin-bottom: 10px; }

У цій версії компонент App передає булевий проп замість функції. Всередині ефекту ви вирішуєте, яку функцію використовувати. Оскільки createEncryptedConnection і createUnencryptedConnection оголошені за межами компонента, вони не є реактивними і не повинні бути залежними. Докладніше про це ви дізнаєтеся у статті Видалення залежностей ефектів.

Заповнити ланцюжок вибраних полів

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

Подивіться, як працює перше поле вибору. Воно заповнює стан planetList результатом виклику API "/planets". Ідентифікатор поточно вибраної планети зберігається у змінній стану planetId. Потрібно знайти, де додати додатковий код, щоб змінна стану placeList була заповнена результатом виклику "/planets/" + planetId + "/places" API.

Якщо ви реалізуєте це право, вибір планети має заповнити список місць. Зміна планети має змінювати список місць.

Якщо ви маєте два незалежні процеси синхронізації, вам потрібно написати два окремі ефекти.

import { useState, useEffect } from 'react';
import { fetchData } from './api.js';

export default function Page() {
  const [planetList, setPlanetList] = useState([])
  const [planetId, setPlanetId] = useState('');

  const [placeList, setPlaceList] = useState([]);
  const [placeId, setPlaceId] = useState('');

  useEffect(() => {
    let ignore = false;
    fetchData('/planets').then(result => {
      if (!ignore) {
        console.log('Fetched a list of planets.');
        setPlanetList(result);
        setPlanetId(result[0].id); // Select the first planet
      }
    });
    return () => {
      ignore = true;
    }
  }, []);

  return (
    <>
      <label>
        Pick a planet:{' '}
        <select value={planetId} onChange={e => {
          setPlanetId(e.target.value);
        }}>
          {planetList.map(planet =>
            <option key={planet.id} value={planet.id}>{planet.name}</option>
          )}
        </select>
      </label>
      <label>
        Pick a place:{' '}
        <select value={placeId} onChange={e => {
          setPlaceId(e.target.value);
        }}>
          {placeList.map(place =>
            <option key={place.id} value={place.id}>{place.name}</option>
          )}
        </select>
      </label>
      <hr />
      <p>You are going to: {placeId || '???'} on {planetId || '???'} </p>
    </>
  );
}
export function fetchData(url) {
  if (url === '/planets') {
    return fetchPlanets();
  } else if (url.startsWith('/planets/')) {
    const match = url.match(/^\/planets\/([\w-]+)\/places(\/)?$/);
    if (!match || !match[1] || !match[1].length) {
      throw Error('Expected URL like "/planets/earth/places". Received: "' + url + '".');
    }
    return fetchPlaces(match[1]);
  } else throw Error('Expected URL like "/planets" or "/planets/earth/places". Received: "' + url + '".');
}

async function fetchPlanets() {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve([{
        id: 'earth',
        name: 'Earth'
      }, {
        id: 'venus',
        name: 'Venus'
      }, {
        id: 'mars',
        name: 'Mars'        
      }]);
    }, 1000);
  });
}

async function fetchPlaces(planetId) {
  if (typeof planetId !== 'string') {
    throw Error(
      'fetchPlaces(planetId) expects a string argument. ' +
      'Instead received: ' + planetId + '.'
    );
  }
  return new Promise(resolve => {
    setTimeout(() => {
      if (planetId === 'earth') {
        resolve([{
          id: 'laos',
          name: 'Laos'
        }, {
          id: 'spain',
          name: 'Spain'
        }, {
          id: 'vietnam',
          name: 'Vietnam'        
        }]);
      } else if (planetId === 'venus') {
        resolve([{
          id: 'aurelia',
          name: 'Aurelia'
        }, {
          id: 'diana-chasma',
          name: 'Diana Chasma'
        }, {
          id: 'kumsong-vallis',
          name: 'Kŭmsŏng Vallis'        
        }]);
      } else if (planetId === 'mars') {
        resolve([{
          id: 'aluminum-city',
          name: 'Aluminum City'
        }, {
          id: 'new-new-york',
          name: 'New New York'
        }, {
          id: 'vishniac',
          name: 'Vishniac'
        }]);
      } else throw Error('Unknown planet ID: ' + planetId);
    }, 1000);
  });
}
label { display: block; margin-bottom: 10px; }

Існує два незалежні процеси синхронізації:

  • Перше поле вибору синхронізовано з віддаленим списком планет.
  • Друге поле вибору синхронізовано з віддаленим списком місць для поточного planetId.

Тому має сенс описати їх як два окремі ефекти. Ось приклад того, як це можна зробити:

import { useState, useEffect } from 'react';
import { fetchData } from './api.js';

export default function Page() {
  const [planetList, setPlanetList] = useState([])
  const [planetId, setPlanetId] = useState('');

  const [placeList, setPlaceList] = useState([]);
  const [placeId, setPlaceId] = useState('');

  useEffect(() => {
    let ignore = false;
    fetchData('/planets').then(result => {
      if (!ignore) {
        console.log('Fetched a list of planets.');
        setPlanetList(result);
        setPlanetId(result[0].id); // Select the first planet
      }
    });
    return () => {
      ignore = true;
    }
  }, []);

  useEffect(() => {
    if (planetId === '') {
      // Nothing is selected in the first box yet
      return;
    }

    let ignore = false;
    fetchData('/planets/' + planetId + '/places').then(result => {
      if (!ignore) {
        console.log('Fetched a list of places on "' + planetId + '".');
        setPlaceList(result);
        setPlaceId(result[0].id); // Select the first place
      }
    });
    return () => {
      ignore = true;
    }
  }, [planetId]);

  return (
    <>
      <label>
        Pick a planet:{' '}
        <select value={planetId} onChange={e => {
          setPlanetId(e.target.value);
        }}>
          {planetList.map(planet =>
            <option key={planet.id} value={planet.id}>{planet.name}</option>
          )}
        </select>
      </label>
      <label>
        Pick a place:{' '}
        <select value={placeId} onChange={e => {
          setPlaceId(e.target.value);
        }}>
          {placeList.map(place =>
            <option key={place.id} value={place.id}>{place.name}</option>
          )}
        </select>
      </label>
      <hr />
      <p>You are going to: {placeId || '???'} on {planetId || '???'} </p>
    </>
  );
}
export function fetchData(url) {
  if (url === '/planets') {
    return fetchPlanets();
  } else if (url.startsWith('/planets/')) {
    const match = url.match(/^\/planets\/([\w-]+)\/places(\/)?$/);
    if (!match || !match[1] || !match[1].length) {
      throw Error('Expected URL like "/planets/earth/places". Received: "' + url + '".');
    }
    return fetchPlaces(match[1]);
  } else throw Error('Expected URL like "/planets" or "/planets/earth/places". Received: "' + url + '".');
}

async function fetchPlanets() {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve([{
        id: 'earth',
        name: 'Earth'
      }, {
        id: 'venus',
        name: 'Venus'
      }, {
        id: 'mars',
        name: 'Mars'        
      }]);
    }, 1000);
  });
}

async function fetchPlaces(planetId) {
  if (typeof planetId !== 'string') {
    throw Error(
      'fetchPlaces(planetId) expects a string argument. ' +
      'Instead received: ' + planetId + '.'
    );
  }
  return new Promise(resolve => {
    setTimeout(() => {
      if (planetId === 'earth') {
        resolve([{
          id: 'laos',
          name: 'Laos'
        }, {
          id: 'spain',
          name: 'Spain'
        }, {
          id: 'vietnam',
          name: 'Vietnam'        
        }]);
      } else if (planetId === 'venus') {
        resolve([{
          id: 'aurelia',
          name: 'Aurelia'
        }, {
          id: 'diana-chasma',
          name: 'Diana Chasma'
        }, {
          id: 'kumsong-vallis',
          name: 'Kŭmsŏng Vallis'        
        }]);
      } else if (planetId === 'mars') {
        resolve([{
          id: 'aluminum-city',
          name: 'Aluminum City'
        }, {
          id: 'new-new-york',
          name: 'New New York'
        }, {
          id: 'vishniac',
          name: 'Vishniac'
        }]);
      } else throw Error('Unknown planet ID: ' + planetId);
    }, 1000);
  });
}
label { display: block; margin-bottom: 10px; }

Цей код дещо повторюється. Однак це не причина об'єднувати його в один ефект! Якщо ви це зробите, вам доведеться об'єднати обидва ефекти в один список, і тоді зміна планети призведе до перезавантаження списку всіх планет. Ефекти не є інструментом для повторного використання коду.

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

import { useState } from 'react';
import { useSelectOptions } from './useSelectOptions.js';

export default function Page() {
  const [
    planetList,
    planetId,
    setPlanetId
  ] = useSelectOptions('/planets');

  const [
    placeList,
    placeId,
    setPlaceId
  ] = useSelectOptions(planetId ? `/planets/${planetId}/places` : null);

  return (
    <>
      <label>
        Pick a planet:{' '}
        <select value={planetId} onChange={e => {
          setPlanetId(e.target.value);
        }}>
          {planetList?.map(planet =>
            <option key={planet.id} value={planet.id}>{planet.name}</option>
          )}
        </select>
      </label>
      <label>
        Pick a place:{' '}
        <select value={placeId} onChange={e => {
          setPlaceId(e.target.value);
        }}>
          {placeList?.map(place =>
            <option key={place.id} value={place.id}>{place.name}</option>
          )}
        </select>
      </label>
      <hr />
      <p>You are going to: {placeId || '...'} on {planetId || '...'} </p>
    </>
  );
}
import { useState, useEffect } from 'react';
import { fetchData } from './api.js';

export function useSelectOptions(url) {
  const [list, setList] = useState(null);
  const [selectedId, setSelectedId] = useState('');
  useEffect(() => {
    if (url === null) {
      return;
    }

    let ignore = false;
    fetchData(url).then(result => {
      if (!ignore) {
        setList(result);
        setSelectedId(result[0].id);
      }
    });
    return () => {
      ignore = true;
    }
  }, [url]);
  return [list, selectedId, setSelectedId];
}
export function fetchData(url) {
  if (url === '/planets') {
    return fetchPlanets();
  } else if (url.startsWith('/planets/')) {
    const match = url.match(/^\/planets\/([\w-]+)\/places(\/)?$/);
    if (!match || !match[1] || !match[1].length) {
      throw Error('Expected URL like "/planets/earth/places". Received: "' + url + '".');
    }
    return fetchPlaces(match[1]);
  } else throw Error('Expected URL like "/planets" or "/planets/earth/places". Received: "' + url + '".');
}

async function fetchPlanets() {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve([{
        id: 'earth',
        name: 'Earth'
      }, {
        id: 'venus',
        name: 'Venus'
      }, {
        id: 'mars',
        name: 'Mars'        
      }]);
    }, 1000);
  });
}

async function fetchPlaces(planetId) {
  if (typeof planetId !== 'string') {
    throw Error(
      'fetchPlaces(planetId) expects a string argument. ' +
      'Instead received: ' + planetId + '.'
    );
  }
  return new Promise(resolve => {
    setTimeout(() => {
      if (planetId === 'earth') {
        resolve([{
          id: 'laos',
          name: 'Laos'
        }, {
          id: 'spain',
          name: 'Spain'
        }, {
          id: 'vietnam',
          name: 'Vietnam'        
        }]);
      } else if (planetId === 'venus') {
        resolve([{
          id: 'aurelia',
          name: 'Aurelia'
        }, {
          id: 'diana-chasma',
          name: 'Diana Chasma'
        }, {
          id: 'kumsong-vallis',
          name: 'Kŭmsŏng Vallis'        
        }]);
      } else if (planetId === 'mars') {
        resolve([{
          id: 'aluminum-city',
          name: 'Aluminum City'
        }, {
          id: 'new-new-york',
          name: 'New New York'
        }, {
          id: 'vishniac',
          name: 'Vishniac'
        }]);
      } else throw Error('Unknown planet ID: ' + planetId);
    }, 1000);
  });
}
label { display: block; margin-bottom: 10px; }

Перевірте вкладку useSelectOptions.js у пісочниці, щоб побачити, як це працює. В ідеалі, більшість ефектів у вашій програмі слід замінити користувацькими хуками, написаними вами або спільнотою. Користувацькі хуки приховують логіку синхронізації, тому компонент, який їх викликає, не знає про ефект. Продовжуючи працювати над своїм застосунком, ви створите набір хуків на вибір, і зрештою вам не доведеться часто писати ефекти у своїх компонентах.