Синхронізація з ефектами

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

  • Які ефекти є
  • Чим ефекти відрізняються від подій
  • Як оголосити ефект у вашому компоненті
  • Як пропустити повторний запуск ефекту без потреби
  • .
  • Чому ефекти запускаються двічі під час розробки та як це виправити

Що таке ефекти і чим вони відрізняються від подій?

Перш ніж перейти до ефектів, вам потрібно ознайомитися з двома типами логіки всередині React-компонентів:

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

  • Обробники подій (введено у Додаванні інтерактивності) - це вкладені функції всередині ваших компонентів, які роблять певні дії, а не просто обчислюють їх. Обробник події може оновити поле введення, відправити HTTP POST-запит на купівлю товару або перенаправити користувача на інший екран. Обробники подій містять "побічні ефекти" (вони змінюють стан програми), спричинені певною дією користувача (наприклад, натисканням кнопки або введенням тексту).

Іноді цього недостатньо. Розглянемо компонент ChatRoom, який повинен з'єднуватися з сервером чату щоразу, коли його видно на екрані. Підключення до сервера не є чистим обчисленням (це побічний ефект), тому воно не може відбутися під час рендерингу. Втім, не існує жодної конкретної події на кшталт клацання мишею, яка спричиняє відображення ChatRoom.

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

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

Можливо, вам не знадобиться ефект

.

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

Як написати ефект

Щоб написати Ефект, виконайте наступні три кроки:

  1. Оголосіть ефект. За замовчуванням ваш ефект запускатиметься після кожного рендерингу.
  2. Вкажіть залежності ефектів. Більшість ефектів слід повторно запускати лише за необхідності, а не після кожного рендеру. Наприклад, анімація згладжування має спрацьовувати лише тоді, коли з'являється компонент. Підключення та відключення від чату має відбуватися лише тоді, коли компонент з'являється та зникає, або коли змінюється чат-кімната. Ви дізнаєтеся, як керувати цим, вказавши залежності.
  3. Додайте очищення, якщо потрібно. Для деяких ефектів потрібно вказати, як зупинити, скасувати або очистити те, що вони робили. Наприклад, для "connect" потрібно вказати "disconnect", для "subscribe" - "unsubscribe", а для "fetch" - "cancel" або "ignore". Ви дізнаєтеся, як це зробити, повернувши функцію очищення ..

Давайте детально розглянемо кожен з цих кроків.

Крок 1: Оголошення ефекту

Щоб оголосити ефект у вашому компоненті, імпортуйте useEffect хук з React:

import { useEffect } from 'react';

Потім викличте його на верхньому рівні вашого компонента і додайте код всередину ефекту:

function MyComponent() {
  useEffect(() => {
    // Code here will run after *every* render
  });
  return <div />;
}

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

Давайте подивимося, як можна використовувати ефект для синхронізації із зовнішньою системою. Розглянемо React-компонент <VideoPlayer>. Було б непогано контролювати, грає він чи перестав, передавши йому проп isPlaying:

<VideoPlayer isPlaying={isPlaying} />;

Ваш кастомний VideoPlayer компонент рендерить вбудований тег браузера <video>:

function VideoPlayer({ src, isPlaying }) {
  // TODO: do something with isPlaying
  return <video src={src} />;
}

Проте тег браузера <video> не має пропу isPlaying. Єдиний спосіб контролювати його - вручну викликати методи play() і pause() у DOM-елементі. Вам потрібно синхронізувати значення пропу isPlaying, який вказує, чи слід відтворювати відео чи , з викликами на кшталт play() та pause().

Спочатку нам потрібно отримати посилання на вузол DOM <video>.

У вас може виникнути спокуса спробувати викликати play() або pause() під час рендерингу, але це неправильно:

import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  if (isPlaying) {
    ref.current.play();  // Calling these while rendering isn't allowed.
  } else {
    ref.current.pause(); // Also, this crashes.
  }

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  return (
    <>
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}
button { display: block; margin-bottom: 20px; }
video { width: 250px; }

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

Більше того, коли VideoPlayer викликається вперше, його DOM ще не існує! Ще не існує DOM-вузла для виклику play() або pause(), тому що React не знає, який DOM створити, поки ви не повернете JSX.

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

import { useEffect, useRef } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  });

  return <video ref={ref} src={src} loop playsInline />;
}

Обгортаючи оновлення DOM в ефект, ви дозволяєте React спочатку оновити екран. Потім запускається ваш ефект.

Коли ваш компонент VideoPlayer відрендериться (або вперше, або при повторному рендері), відбудеться кілька речей. По-перше, React оновить екран, переконавшись, що тег <video> знаходиться в DOM з правильними пропсами. Потім React запустить ваш ефект. Нарешті, ваш ефект викличе play() або pause() в залежності від значення isPlaying.

Натисніть кнопку відтворення/паузи кілька разів і подивіться, як відеоплеєр залишається синхронізованим зі значенням isPlaying:

import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  });

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  return (
    <>
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}
button { display: block; margin-bottom: 20px; }
video { width: 250px; }

У цьому прикладі "зовнішньою системою", яку ви синхронізували до стану React, був медіа API браузера. Ви можете використовувати подібний підхід для обгортання застарілого нереактівського коду (наприклад, плагінів jQuery) у декларативні React-компоненти.

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

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

const [count, setCount] = useState(0);
useEffect(() => {
  setCount(count + 1);
});

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

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

Крок 2: Вказуємо залежності ефектів

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

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

Щоб продемонструвати проблему, наведемо попередній приклад з кількома викликами console.log і введенням тексту, який оновлює стан батьківського компонента. Зверніть увагу, як введення тексту призводить до повторного запуску ефекту:

import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      console.log('Calling video.play()');
      ref.current.play();
    } else {
      console.log('Calling video.pause()');
      ref.current.pause();
    }
  });

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  const [text, setText] = useState('');
  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}
input, button { display: block; margin-bottom: 20px; }
video { width: 250px; }

Ви можете змусити React пропустити непотрібні повторні запуски ефекту , вказавши масив залежностей як другий аргумент виклику useEffect. Почніть з додавання порожнього масиву [] до наведеного вище прикладу у рядку 14:

useEffect(() => {
    // ...
  }, []);

Ви маєте побачити помилку React Hook useEffect has a missing dependency: 'isPlaying':

import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      console.log('Calling video.play()');
      ref.current.play();
    } else {
      console.log('Calling video.pause()');
      ref.current.pause();
    }
  }, []); // This causes an error

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  const [text, setText] = useState('');
  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}
input, button { display: block; margin-bottom: 20px; }
video { width: 250px; }

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

useEffect(() => {
    if (isPlaying) { // It's used here...
      // ...
    } else {
      // ...
    }
  }, [isPlaying]); // ...so it must be declared here!

Тепер усі залежності оголошено, тому помилки не буде. Якщо вказати [isPlaying] як масив залежностей, React скаже, що йому слід пропустити повторний запуск вашого ефекту, якщо isPlaying такий самий, як і під час попереднього рендерингу. З цією зміною введення даних не призводить до повторного запуску ефекту, але натискання кнопки відтворення/паузи призводить:

import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      console.log('Calling video.play()');
      ref.current.play();
    } else {
      console.log('Calling video.pause()');
      ref.current.pause();
    }
  }, [isPlaying]);

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  const [text, setText] = useState('');
  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}
input, button { display: block; margin-bottom: 20px; }
video { width: 250px; }

Масив залежностей може містити декілька залежностей. React пропустить повторний запуск ефекту, якщо всі вказані вами залежності мають точно такі ж значення, як і під час попереднього рендерингу. React порівнює значення залежностей за допомогою порівняння Object.is. Дивіться useEffect у довіднику для отримання детальної інформації.

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

Поведінка без масиву залежностей та з порожнім [] масивом залежностей відрізняється:

useEffect(() => {
  // This runs after every render
});

useEffect(() => {
  // This runs only on mount (when the component appears)
}, []);

useEffect(() => {
  // This runs on mount *and also* if either a or b have changed since the last render
}, [a, b]);

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

Чому було пропущено посилання у масиві залежностей?

Цей ефект використовує як ref, так і isPlaying, але тільки isPlaying оголошено як залежність:

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);
  useEffect(() => {
    if (isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  }, [isPlaying]);

Це тому, що об'єкт ref має стабільну ідентичність: React гарантує, що ви завжди отримаєте той самий об'єкт з того самого виклику useRef при кожному рендерингу. Він ніколи не змінюється, тому сам по собі ніколи не призведе до повторного запуску ефекту. Тому не має значення, включите ви його чи ні. Увімкнення також є нормальним:

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);
  useEffect(() => {
    if (isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  }, [isPlaying, ref]);

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

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

Крок 3: Додайте очищення, якщо потрібно

Розглянемо інший приклад. Ви пишете компонент ChatRoom, який має з'єднуватися з сервером чату, коли той з'являється. Вам надано createConnection() API, який повертає об'єкт з методами connect() та disconnect(). Як утримати компонент підключеним під час його відображення користувачеві?

Почніть з написання логіки ефекту:

useEffect(() => {
  const connection = createConnection();
  connection.connect();
});

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

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

Код всередині ефекту не використовує жодних пропсів чи станів, тому ваш масив залежностей є [] (порожнім). Це вказує React запускати цей код лише тоді, коли компонент "монтується", тобто з'являється на екрані вперше.

Спробуємо запустити цей код:

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

export default function ChatRoom() {
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
  }, []);
  return <h1>Welcome to the chat!</h1>;
}
export function createConnection() {
  // A real implementation would actually connect to the server
  return {
    connect() {
      console.log('✅ Connecting...');
    },
    disconnect() {
      console.log('❌ Disconnected.');
    }
  };
}
input { display: block; margin-bottom: 20px; }

Цей ефект працює лише у змонтованому стані, тому ви можете очікувати, що "✅ Connecting..." буде виведено один раз у консолі. Втім, якщо ви перевірите консоль, "✅ Connecting..." буде виведено двічі. Чому це відбувається?

Уявіть, що компонент ChatRoom є частиною більшої програми з багатьма різними екранами. Користувач починає свою подорож на сторінці ChatRoom. Компонент монтує і викликає connection.connect(). Потім уявіть, що користувач переходить на інший екран - наприклад, на сторінку налаштувань. Компонент ChatRoom демонтується. Нарешті, користувач натискає кнопку Назад і ChatRoom монтується знову. Це створить друге з'єднання - але перше з'єднання не було зруйновано! Оскільки користувач переміщувався по програмі, з'єднання продовжували накопичуватися.

Такі проблеми легко пропустити без ретельного ручного тестування. Щоб допомогти вам швидко їх виявити, під час розробки React перемонтує кожен компонент один раз одразу після його початкового монтування.

Двічі переглянувши журнал "✅ Connecting...", ви помітите справжню проблему: ваш код не закриває з'єднання, коли компонент демонтується.

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

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

React викликатиме вашу функцію очищення щоразу перед повторним запуском ефекту і востаннє, коли компонент буде демонтовано (видалено). Давайте подивимося, що станеться, коли функція очищення буде реалізована:

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

export default function ChatRoom() {
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, []);
  return <h1>Welcome to the chat!</h1>;
}
export function createConnection() {
  // A real implementation would actually connect to the server
  return {
    connect() {
      console.log('✅ Connecting...');
    },
    disconnect() {
      console.log('❌ Disconnected.');
    }
  };
}
input { display: block; margin-bottom: 20px; }

Тепер ви отримаєте три консольні журнали у розробці:

  1. "✅ Connecting..."
  2. "❌ Disconnected."
  3. "✅ Connecting..."

Це правильна поведінка під час розробки. Змонтувавши ваш компонент, React перевіряє, що перехід туди і назад не порушить ваш код. Від'єднання та повторне з'єднання - це саме те, що має відбуватися! Коли ви добре реалізуєте очищення, не повинно бути ніякої видимої користувачеві різниці між запуском ефекту один раз і його запуском, очищенням та повторним запуском. Існує додаткова пара викликів connect/disconnect, тому що React перевіряє ваш код на наявність помилок під час розробки. Це нормально - не намагайтеся це виправити!

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

Як впоратися з подвійним запуском ефекту у розробці?

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

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

Більшість ефектів, які ви напишете, відповідатимуть одному з наведених нижче шаблонів.

Керування віджетами, що не належать до React

Іноді вам потрібно буде додати віджети інтерфейсу користувача, які не написані на React. Наприклад, скажімо, ви додаєте на сторінку компонент мапи. Він має метод setZoomLevel(), і ви хочете синхронізувати рівень масштабування зі змінною стану zoomLevel у вашому React-коді. Ваш ефект буде виглядати приблизно так:

useEffect(() => {
  const map = mapRef.current;
  map.setZoomLevel(zoomLevel);
}, [zoomLevel]);

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

Деякі API можуть не дозволяти викликати їх двічі поспіль. Наприклад, метод showModal вбудованого елемента <dialog> викидається, якщо викликати його двічі. Реалізуйте функцію очищення і зробіть так, щоб вона закривала діалог:

useEffect(() => {
  const dialog = dialogRef.current;
  dialog.showModal();
  return () => dialog.close();
}, []);

Під час розробки ваш ефект викликатиме showModal(), потім одразу close(), а потім знову showModal(). Це має таку саму видиму для користувача поведінку, як і однократний виклик showModal(), як ви побачите у виробництві.

Підписка на події

Якщо ваш ефект підписаний на щось, функція очищення повинна відписатися:

useEffect(() => {
  function handleScroll(e) {
    console.log(window.scrollX, window.scrollY);
  }
  window.addEventListener('scroll', handleScroll);
  return () => window.removeEventListener('scroll', handleScroll);
}, []);

Під час розробки ваш ефект викликатиме addEventListener(), потім одразу removeEventListener(), а потім знову addEventListener() з тим самим обробником. Таким чином, одночасно буде лише одна активна підписка. Це має таку саму видиму для користувача поведінку, як і однократний виклик addEventListener(), як і у виробництві.

Запуск анімації

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

useEffect(() => {
  const node = ref.current;
  node.style.opacity = 1; // Trigger the animation
  return () => {
    node.style.opacity = 0; // Reset to the initial value
  };
}, []);

У розробці непрозорість буде встановлено на 1, потім на 0, а потім знову на 1. Це повинно мати таку саму видиму для користувача поведінку, як і встановлення значення 1 безпосередньо, що і відбудеться під час виробництва. Якщо ви використовуєте сторонню бібліотеку анімації з підтримкою твінів, ваша функція очищення має скинути шкалу часу до початкового стану.

Отримання даних

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

useEffect(() => {
  let ignore = false;

  async function startFetching() {
    const json = await fetchTodos(userId);
    if (!ignore) {
      setTodos(json);
    }
  }

  startFetching();

  return () => {
    ignore = true;
  };
}, [userId]);

Ви не можете "скасувати" мережевий запит, який вже відбувся, але ваша функція очищення повинна гарантувати, що вибірка, яка більше не є релевантною, не впливатиме на ваш застосунок. Якщо userId змінюється з 'Alice' на 'Bob', очищення гарантує, що відповідь 'Alice' буде проігноровано, навіть якщо вона надійде після 'Bob'.

Під час розробки ви побачите дві вибірки на вкладці Мережа. У цьому немає нічого поганого. За описаного вище підходу перший ефект буде негайно очищено, тому його копію змінної ignore буде встановлено у значення true. Таким чином, навіть якщо є додатковий запит, він не вплине на стан завдяки перевірці if (!ignore).

У виробництві буде лише один запит. Якщо другий запит у розробці вас турбує, найкращим підходом буде використання рішення, яке дедукує запити та кешує відповіді на них між компонентами:

function TodoList() {
  const todos = useSomeDataLibrary(`/api/user/${userId}/todos`);
  // ...

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

Які є хороші альтернативи отриманню даних у Effects?

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

  • Ефекти не виконуються на сервері. Це означає, що початковий серверний HTML буде містити лише стан завантаження без даних. Клієнтському комп'ютеру доведеться завантажити весь JavaScript і відрендерити ваш застосунок лише для того, щоб виявити, що тепер йому потрібно завантажити дані. Це не дуже ефективно.
  • Отримання даних безпосередньо в ефектах полегшує створення "мережевих водоспадів". Ви рендерите батьківський компонент, він отримує деякі дані, рендерить дочірні компоненти, а потім вони починають отримувати свої дані. Якщо мережа не дуже швидка, це значно повільніше, ніж отримувати всі дані паралельно.
  • Отримання даних безпосередньо в ефектах зазвичай означає, що ви не завантажуєте і не кешуєте дані. Наприклад, якщо компонент демонтується, а потім монтується знову, йому доведеться отримати дані знову.
  • Це не дуже ергономічно. При написанні викликів fetch задіяно досить багато шаблонного коду, який не страждає від помилок на кшталт станів гонитви.

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

  • Якщо ви використовуєте фреймворк, використовуйте його вбудований механізм отримання даних.Сучасні фреймворки React мають інтегровані механізми отримання даних, які є ефективними і не страждають від вищезгаданих пасток.
  • В іншому випадку розгляньте можливість використання або створення кешу на стороні клієнта. Популярні рішення з відкритим кодом включають React Query, useSWR та React Router 6.4+. Ви також можете створити власне рішення, і тоді ви будете використовувати Effects, але додасте логіку для дедуплікації запитів, кешування відповідей та уникнення мережевих водоспадів (шляхом попереднього завантаження даних або підняття вимог до даних у маршрути).

Ви можете продовжити отримувати дані безпосередньо в Ефектах, якщо жоден з цих підходів вам не підходить.

Надсилання аналітики

Погляньте на цей код, який надсилає аналітичну подію про відвідування сторінки:

useEffect(() => {
  logVisit(url); // Sends a POST request
}, [url]);

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

У виробництві не буде дублюючих журналів відвідувань.

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

Не є ефектом: ініціалізація програми

Деяка логіка має виконуватися лише один раз під час запуску програми. Ви можете винести її за межі ваших компонентів:

if (typeof window !== 'undefined') { // Check if we're running in the browser.
  checkAuthToken();
  loadDataFromLocalStorage();
}

function App() {
  // ...
}

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

Не впливає: покупка товару

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

useEffect(() => {
  // 🔴 Wrong: This Effect fires twice in development, exposing a problem in the code.
  fetch('/api/buy', { method: 'POST' });
}, []);

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

Купівля викликається не рендерингом, а специфічною взаємодією. Вона має виконуватися лише тоді, коли користувач натискає кнопку. Видаліть ефект і перемістіть запит /api/buy в обробник події кнопки "Купити":

function handleClick() {
    // ✅ Buying is an event because it is caused by a particular interaction.
    fetch('/api/buy', { method: 'POST' });
  }

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

Збираємо все разом

Цей майданчик допоможе вам "відчути", як ефекти працюють на практиці.

У цьому прикладі використовується setTimeout для запланованого виведення консольного журналу з текстом введення через три секунди після запуску ефекту. Функція очищення скасовує очікуваний таймаут. Почніть роботу, натиснувши кнопку "Змонтувати компонент":

import { useState, useEffect } from 'react';

function Playground() {
  const [text, setText] = useState('a');

  useEffect(() => {
    function onTimeout() {
      console.log('⏰ ' + text);
    }

    console.log('🔵 Schedule "' + text + '" log');
    const timeoutId = setTimeout(onTimeout, 3000);

    return () => {
      console.log('🟡 Cancel "' + text + '" log');
      clearTimeout(timeoutId);
    };
  }, [text]);

  return (
    <>
      <label>
        What to log:{' '}
        <input
          value={text}
          onChange={e => setText(e.target.value)}
        />
      </label>
      <h1>{text}</h1>
    </>
  );
}

export default function App() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => setShow(!show)}>
        {show ? 'Unmount' : 'Mount'} the component
      </button>
      {show && <hr />}
      {show && <Playground />}
    </>
  );
}

Спочатку ви побачите три журнали: Schedule "a" log, Cancel "a" log і знову Schedule "a" log. Через три секунди також буде зроблено запис у журналі a. Як ви дізналися раніше, додаткова пара розклад/скасування пов'язана з тим, що React перезбирає компонент один раз під час розробки, щоб перевірити, чи добре ви реалізували очищення.

Тепер змініть вхідні дані на abc. Якщо ви зробите це досить швидко, ви побачите Schedule "ab" log, а потім Cancel "ab" log і Schedule "abc" log. React завжди очищає ефект попереднього рендерингу перед наступним рендерингом. Ось чому, навіть якщо ви швидко вводите дані, за один раз заплановано максимум один таймаут. Відредагуйте введені дані кілька разів і подивіться на консоль, щоб зрозуміти, як очищаються ефекти.

Введіть щось у поле вводу, а потім одразу натисніть "Демонтувати компонент". Зверніть увагу, що демонтаж очищає ефект останнього рендеру. Тут він очищає останній таймаут перед тим, як він матиме шанс вистрілити.

Нарешті, відредагуйте компонент вище і закоментуйте функцію очищення, щоб таймаут не скасовувався. Спробуйте швидко набрати abcde. Що, на вашу думку, станеться через три секунди? Чи виведе console.log(text) протягом таймауту останній текст і створить п'ять abcde логів? Спробуйте, щоб перевірити свою інтуїцію!

Через три секунди ви маєте побачити послідовність логів (a, ab, abc, abcd і abcde) замість п'яти abcde. Кожен ефект "захоплює" значення text з відповідного рендерингу. Не має значення, що стан text змінився: ефект з рендерингу з text = 'ab' завжди буде бачити 'ab'. Іншими словами, ефекти з кожного рендерингу ізольовані один від одного. Якщо вам цікаво, як це працює, ви можете прочитати про закриття.

Кожен рендер має власні ефекти

Ви можете думати про useEffect як про "прикріплення" частини поведінки до результатів відображення. Розглянемо цей ефект:

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

  return <h1>Welcome to {roomId}!</h1>;
}

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

Початковий рендеринг

Користувач відвідує <ChatRoom roomId="general" />. Давайте подумки замінимо roomId на 'general':

// JSX for the first render (roomId = "general")
  return <h1>Welcome to general!</h1>;

Ефект є такожчастиною виводу рендеру. Ефектом першого рендеру стає:

// Effect for the first render (roomId = "general")
  () => {
    const connection = createConnection('general');
    connection.connect();
    return () => connection.disconnect();
  },
  // Dependencies for the first render (roomId = "general")
  ['general']

React запускає цей ефект, який підключається до чату 'general'.

Повторний рендеринг з тими самими залежностями

Припустимо, <ChatRoom roomId="general" /> повторно рендерить. Вивід у JSX буде таким самим:

// JSX for the second render (roomId = "general")
  return <h1>Welcome to general!</h1>;

React бачить, що вивід рендерингу не змінився, тому не оновлює DOM.

Ефект від другого рендеру виглядає так:

// Effect for the second render (roomId = "general")
  () => {
    const connection = createConnection('general');
    connection.connect();
    return () => connection.disconnect();
  },
  // Dependencies for the second render (roomId = "general")
  ['general']

React порівнює ['general'] з другого рендерингу з ['general'] з першого рендерингу. Оскільки всі залежності однакові, React ігнорує ефект з другого рендерингу. Він ніколи не викликається.

Повторний рендеринг з іншими залежностями

Далі користувач відвідує <ChatRoom roomId="travel" />. Цього разу компонент повертає інший JSX:

// JSX for the third render (roomId = "travel")
  return <h1>Welcome to travel!</h1>;

React оновлює DOM, щоб змінити "Welcome to general" на "Welcome to travel".

Ефект від третього рендеру виглядає так:

// Effect for the third render (roomId = "travel")
  () => {
    const connection = createConnection('travel');
    connection.connect();
    return () => connection.disconnect();
  },
  // Dependencies for the third render (roomId = "travel")
  ['travel']

React порівнює ['travel'] з третього рендерингу з ['general'] з другого рендерингу. Одна залежність відрізняється від іншої: Object.is('travel', 'general') є false. Ефект не може бути пропущено.

Перш ніж React зможе застосувати ефект з третього рендерингу, він має очистити останній ефект, який запустив. Ефект другого рендерингу було пропущено, тому React має очистити ефект першого рендерингу. Якщо ви прокрутите до першого рендерингу, то побачите, що його очищення викликає disconnect() у з'єднанні, яке було створено за допомогою createConnection('general'). Це від'єднає програму від чату 'general'.

Після цього React запускає ефект третього рендерингу. Він підключається до чату 'travel'.

Unmount

Нарешті, припустимо, що користувач пішов, і компонент ChatRoom демонтовано. React запускає функцію очищення останнього ефекту. Останній ефект був з третього рендерингу. Очищення третього рендерингу руйнує з'єднання createConnection('travel'). Таким чином, програма від'єднується від кімнати 'travel'.

Поведінка лише для розробки

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

  • На відміну від подій, ефекти викликаються самим рендерингом, а не певною взаємодією.
  • Ефекти дозволяють синхронізувати компонент з деякою зовнішньою системою (стороннім API, мережею тощо).
  • За замовчуванням ефекти запускаються після кожного рендеру (включаючи початковий).
  • React пропустить ефект, якщо всі його залежності мають ті самі значення, що й під час останнього рендерингу.
  • Ви не можете "вибирати" свої залежності. Вони визначаються кодом всередині ефекту
  • .
  • Порожній масив залежностей ([]) відповідає компоненту "монтування", тобто додавання на екран.
  • У суворому режимі React монтує компоненти двічі (лише у розробці!) для стрес-тестування ваших ефектів.
  • Якщо ваш ефект зламався через перемонтування, вам потрібно реалізувати функцію очищення.
  • React викличе вашу функцію очищення перед наступним запуском ефекту та під час демонтажу.

Фокусування поля на монтуванні

У цьому прикладі форма рендерить компонент <MyInput /> .

Використовуйте метод введення focus() для автоматичного фокусування MyInput, коли він з'являється на екрані. Існує вже прокоментована реалізація, але вона не зовсім працює. З'ясуйте, чому вона не працює, і виправте її. (Якщо ви знайомі з атрибутом autoFocus, уявіть, що його не існує: ми реалізуємо ту саму функціональність з нуля).

import { useEffect, useRef } from 'react';

export default function MyInput({ value, onChange }) {
  const ref = useRef(null);

  // TODO: This doesn't quite work. Fix it.
  // ref.current.focus()    

  return (
    <input
      ref={ref}
      value={value}
      onChange={onChange}
    />
  );
}
import { useState } from 'react';
import MyInput from './MyInput.js';

export default function Form() {
  const [show, setShow] = useState(false);
  const [name, setName] = useState('Taylor');
  const [upper, setUpper] = useState(false);
  return (
    <>
      <button onClick={() => setShow(s => !s)}>{show ? 'Hide' : 'Show'} form</button>
      <br />
      <hr />
      {show && (
        <>
          <label>
            Enter your name:
            <MyInput
              value={name}
              onChange={e => setName(e.target.value)}
            />
          </label>
          <label>
            <input
              type="checkbox"
              checked={upper}
              onChange={e => setUpper(e.target.checked)}
            />
            Make it uppercase
          </label>
          <p>Hello, <b>{upper ? name.toUpperCase() : name}</b></p>
        </>
      )}
    </>
  );
}
label {
  display: block;
  margin-top: 20px;
  margin-bottom: 20px;
}

body {
  min-height: 150px;
}

Щоб переконатися, що ваше рішення працює, натисніть "Показати форму" і переконайтеся, що введення отримує фокус (стає виділеним і курсор знаходиться всередині). Натисніть "Приховати форму" та "Показати форму" ще раз. Переконайтеся, що дані знову виділено.

MyInput слід зосередити увагу лише на монтуванні , а не після кожного рендерингу. Щоб перевірити правильність поведінки, натисніть кнопку "Показати форму", а потім кілька разів оберіть прапорець "Зробити великими літерами". Натискання прапорця має не фокусувати введення над ним.

Виклик ref.current.focus() під час рендерингу є неправильним, оскільки він є побічним ефектом. Побічні ефекти слід або розміщувати всередині обробника події, або оголошувати за допомогою useEffect. У цьому випадку побічний ефект спричиняється появою компонента, а не якоюсь конкретною взаємодією, тому має сенс помістити його в ефект.

Щоб виправити помилку, обгорніть виклик ref.current.focus() в оголошення ефекту. Потім, щоб переконатися, що цей ефект запускається лише при монтуванні, а не після кожного рендерингу, додайте до нього порожні залежності [].

import { useEffect, useRef } from 'react';

export default function MyInput({ value, onChange }) {
  const ref = useRef(null);

  useEffect(() => {
    ref.current.focus();
  }, []);

  return (
    <input
      ref={ref}
      value={value}
      onChange={onChange}
    />
  );
}
import { useState } from 'react';
import MyInput from './MyInput.js';

export default function Form() {
  const [show, setShow] = useState(false);
  const [name, setName] = useState('Taylor');
  const [upper, setUpper] = useState(false);
  return (
    <>
      <button onClick={() => setShow(s => !s)}>{show ? 'Hide' : 'Show'} form</button>
      <br />
      <hr />
      {show && (
        <>
          <label>
            Enter your name:
            <MyInput
              value={name}
              onChange={e => setName(e.target.value)}
            />
          </label>
          <label>
            <input
              type="checkbox"
              checked={upper}
              onChange={e => setUpper(e.target.checked)}
            />
            Make it uppercase
          </label>
          <p>Hello, <b>{upper ? name.toUpperCase() : name}</b></p>
        </>
      )}
    </>
  );
}
label {
  display: block;
  margin-top: 20px;
  margin-bottom: 20px;
}

body {
  min-height: 150px;
}

Умовно сфокусуйте поле

Ця форма рендерить два <MyInput /> компоненти.

Натисніть "Показати форму" і помітьте, що друге поле автоматично фокусується. Це відбувається тому, що обидва компоненти <MyInput /> намагаються сфокусувати поле всередині. Коли ви викликаєте focus() для двох полів введення поспіль, завжди "перемагає" останнє.

Припустимо, ви хочете сфокусувати перше поле. Перший компонент MyInput тепер отримує булевий проп shouldFocus, встановлений як true. Змініть логіку так, щоб focus() викликався тільки якщо проп shouldFocus, отриманий MyInput, має значення true.

import { useEffect, useRef } from 'react';

export default function MyInput({ shouldFocus, value, onChange }) {
  const ref = useRef(null);

  // TODO: call focus() only if shouldFocus is true.
  useEffect(() => {
    ref.current.focus();
  }, []);

  return (
    <input
      ref={ref}
      value={value}
      onChange={onChange}
    />
  );
}
import { useState } from 'react';
import MyInput from './MyInput.js';

export default function Form() {
  const [show, setShow] = useState(false);
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  const [upper, setUpper] = useState(false);
  const name = firstName + ' ' + lastName;
  return (
    <>
      <button onClick={() => setShow(s => !s)}>{show ? 'Hide' : 'Show'} form</button>
      <br />
      <hr />
      {show && (
        <>
          <label>
            Enter your first name:
            <MyInput
              value={firstName}
              onChange={e => setFirstName(e.target.value)}
              shouldFocus={true}
            />
          </label>
          <label>
            Enter your last name:
            <MyInput
              value={lastName}
              onChange={e => setLastName(e.target.value)}
              shouldFocus={false}
            />
          </label>
          <p>Hello, <b>{upper ? name.toUpperCase() : name}</b></p>
        </>
      )}
    </>
  );
}
label {
  display: block;
  margin-top: 20px;
  margin-bottom: 20px;
}

body {
  min-height: 150px;
}

Щоб перевірити своє рішення, натисніть "Показати форму" і "Приховати форму" кілька разів. Коли форма з'явиться, має бути сфокусовано лише вхідні дані першого . Це пов'язано з тим, що батьківський компонент відображає перше введення з shouldFocus={true}, а друге - з shouldFocus={false}. Також перевірте, чи обидва входи все ще працюють і ви можете вводити дані на них.

Ви не можете оголосити ефект умовно, але ваш ефект може містити умовну логіку.

Помістіть умовну логіку всередину ефекту. Вам потрібно вказати shouldFocus як залежність, оскільки ви використовуєте її всередині ефекту. (Це означає, що якщо значення shouldFocus деякого входу зміниться з false на true, він буде сфокусований після монтування.)

import { useEffect, useRef } from 'react';

export default function MyInput({ shouldFocus, value, onChange }) {
  const ref = useRef(null);

  useEffect(() => {
    if (shouldFocus) {
      ref.current.focus();
    }
  }, [shouldFocus]);

  return (
    <input
      ref={ref}
      value={value}
      onChange={onChange}
    />
  );
}
import { useState } from 'react';
import MyInput from './MyInput.js';

export default function Form() {
  const [show, setShow] = useState(false);
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  const [upper, setUpper] = useState(false);
  const name = firstName + ' ' + lastName;
  return (
    <>
      <button onClick={() => setShow(s => !s)}>{show ? 'Hide' : 'Show'} form</button>
      <br />
      <hr />
      {show && (
        <>
          <label>
            Enter your first name:
            <MyInput
              value={firstName}
              onChange={e => setFirstName(e.target.value)}
              shouldFocus={true}
            />
          </label>
          <label>
            Enter your last name:
            <MyInput
              value={lastName}
              onChange={e => setLastName(e.target.value)}
              shouldFocus={false}
            />
          </label>
          <p>Hello, <b>{upper ? name.toUpperCase() : name}</b></p>
        </>
      )}
    </>
  );
}
label {
  display: block;
  margin-top: 20px;
  margin-bottom: 20px;
}

body {
  min-height: 150px;
}

Виправити інтервал, який спрацьовує двічі

Цей компонент Counter відображає лічильник, який має збільшуватися щосекунди. При монтуванні він викликає setInterval. Це призводить до запуску onTick щосекунди. Функція onTick збільшує лічильник.

Втім, замість того, щоб збільшуватися один раз на секунду, він збільшується двічі. Чому так відбувається? Знайдіть причину помилки і виправте її.

Майте на увазі, що setInterval повертає ідентифікатор інтервалу, який ви можете передати до clearInterval, щоб зупинити інтервал.

import { useState, useEffect } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    function onTick() {
      setCount(c => c + 1);
    }

    setInterval(onTick, 1000);
  }, []);

  return <h1>{count}</h1>;
}
import { useState } from 'react';
import Counter from './Counter.js';

export default function Form() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => setShow(s => !s)}>{show ? 'Hide' : 'Show'} counter</button>
      <br />
      <hr />
      {show && <Counter />}
    </>
  );
}
label {
  display: block;
  margin-top: 20px;
  margin-bottom: 20px;
}

body {
  min-height: 150px;
}

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

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

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

import { useState, useEffect } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    function onTick() {
      setCount(c => c + 1);
    }

    const intervalId = setInterval(onTick, 1000);
    return () => clearInterval(intervalId);
  }, []);

  return <h1>{count}</h1>;
}
import { useState } from 'react';
import Counter from './Counter.js';

export default function App() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => setShow(s => !s)}>{show ? 'Hide' : 'Show'} counter</button>
      <br />
      <hr />
      {show && <Counter />}
    </>
  );
}
label {
  display: block;
  margin-top: 20px;
  margin-bottom: 20px;
}

body {
  min-height: 150px;
}

Під час розробки React все одно перезбиратиме ваш компонент один раз, щоб перевірити, чи добре ви реалізували очищення. Отже, буде виклик setInterval, одразу за ним виклик clearInterval, і знову setInterval. У виробництві буде лише один виклик setInterval. Видима для користувача поведінка в обох випадках однакова: лічильник збільшується один раз на секунду.

Виправлено вибірку всередині ефекту

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

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

export default function Page() {
  const [person, setPerson] = useState('Alice');
  const [bio, setBio] = useState(null);

  useEffect(() => {
    setBio(null);
    fetchBio(person).then(result => {
      setBio(result);
    });
  }, [person]);

  return (
    <>
      <select value={person} onChange={e => {
        setPerson(e.target.value);
      }}>
        <option value="Alice">Alice</option>
        <option value="Bob">Bob</option>
        <option value="Taylor">Taylor</option>
      </select>
      <hr />
      <p><i>{bio ?? 'Loading...'}</i></p>
    </>
  );
}
export async function fetchBio(person) {
  const delay = person === 'Bob' ? 2000 : 200;
  return new Promise(resolve => {
    setTimeout(() => {
      resolve('This is ' + person + '’s bio.');
    }, delay);
  })
}

У цьому коді є проблеми. Почніть з вибору "Аліси". Потім виберіть "Боб" і відразу після цього виберіть "Тейлора". Якщо ви зробите це досить швидко, то помітите цю помилку: Тейлора вибрано, але абзацом нижче написано "Це біографія Боба."

Чому це відбувається? Виправте помилку всередині цього ефекту.

Якщо ефект отримує щось асинхронно, його зазвичай потрібно очистити.

Щоб спрацювала проблема, події мають відбуватися у такому порядку:

  • Вибір 'Bob' призводить до fetchBio('Bob')
  • Вибір 'Taylor' викликає fetchBio('Taylor')
  • Отримання 'Taylor' завершує перед отриманням 'Bob'
  • Ефект від викликів 'Taylor' рендеру setBio('This is Taylor’s bio')
  • Отримання 'Bob' завершується
  • Ефект від викликів 'Bob' рендеру setBio('This is Bob’s bio')

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

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

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

export default function Page() {
  const [person, setPerson] = useState('Alice');
  const [bio, setBio] = useState(null);
  useEffect(() => {
    let ignore = false;
    setBio(null);
    fetchBio(person).then(result => {
      if (!ignore) {
        setBio(result);
      }
    });
    return () => {
      ignore = true;
    }
  }, [person]);

  return (
    <>
      <select value={person} onChange={e => {
        setPerson(e.target.value);
      }}>
        <option value="Alice">Alice</option>
        <option value="Bob">Bob</option>
        <option value="Taylor">Taylor</option>
      </select>
      <hr />
      <p><i>{bio ?? 'Loading...'}</i></p>
    </>
  );
}
export async function fetchBio(person) {
  const delay = person === 'Bob' ? 2000 : 200;
  return new Promise(resolve => {
    setTimeout(() => {
      resolve('This is ' + person + '’s bio.');
    }, delay);
  })
}

Кожен ефект рендерингу має власну змінну ignore. Початково змінна ignore має значення false. Однак, якщо ефект буде очищено (наприклад, коли ви виберете іншу особу), його змінна ignore стане true. Таким чином, тепер не має значення, в якому порядку будуть виконані запити. Лише ефект останньої людини матиме значення ignore у false, тому він викличе setBio(result). Попередні ефекти було очищено, тому перевірка if (!ignore) не дозволить їм викликати setBio:

  • Вибір 'Bob' призводить до fetchBio('Bob')
  • Вибір 'Taylor' викликає fetchBio('Taylor') і очищає попередній ефект (Боба)
  • Отримання 'Taylor' завершує перед отриманням 'Bob'
  • Ефект від викликів 'Taylor' рендеру setBio('This is Taylor’s bio')
  • Отримання 'Bob' завершується
  • Ефект від 'Bob' рендеру нічого не робить, оскільки його прапор ігнорувати було встановлено у правду
  • .

Крім ігнорування результату застарілого виклику API, ви також можете використовувати AbortController для скасування запитів, які більше не потрібні. Однак, цього недостатньо для захисту від станів гонитви. Після вибірки може бути виконано більше асинхронних кроків, тому використання явного прапорця на кшталт ignore є найнадійнішим способом виправлення такого типу проблем.