Вам може не знадобитися ефект

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

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

Як видалити непотрібні ефекти

Є два поширені випадки, коли ефекти не потрібні:

  • Вам не потрібні ефекти для перетворення даних для рендерингу. Наприклад, скажімо, ви хочете відфільтрувати список перед його відображенням. Ви можете відчути спокусу написати ефект, який оновлюватиме змінну стану при зміні списку. Однак це неефективно. Коли ви оновлюєте стан, React спочатку викликає функції вашого компонента, щоб обчислити, що має бути на екрані. Потім React "фіксує" ці зміни в DOM, оновивши екран. Після цього React запустить ваші ефекти. Якщо ваш ефект також негайно оновить стан, це перезапустить весь процес з нуля! Щоб уникнути непотрібних відображень, трансформуйте всі дані на верхньому рівні ваших компонентів. Цей код буде автоматично перезапускатися щоразу, коли змінюються пропси або стан.
  • Вам не потрібні ефекти для обробки подій користувача. Наприклад, скажімо, ви хочете відправити /api/buy POST-запит і показати сповіщення, коли користувач купить товар. В обробнику події натискання кнопки "Купити" ви точно знаєте, що сталося. До моменту запуску ефекту ви не знаєте, що зробив користувач (наприклад, яку кнопку було натиснуто). Тому зазвичай ви обробляєте події користувача у відповідних обробниках подій.

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

Щоб допомогти вам набути правильної інтуїції, давайте розглянемо кілька поширених конкретних прикладів!

Оновлення стану на основі пропсів або стану

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

function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');

  // 🔴 Avoid: redundant state and unnecessary Effect
  const [fullName, setFullName] = useState('');
  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);
  // ...
}

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

function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  // ✅ Good: calculated during rendering
  const fullName = firstName + ' ' + lastName;
  // ...
}

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

Кешування складних обчислень

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

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');

  // 🔴 Avoid: redundant state and unnecessary Effect
  const [visibleTodos, setVisibleTodos] = useState([]);
  useEffect(() => {
    setVisibleTodos(getFilteredTodos(todos, filter));
  }, [todos, filter]);

  // ...
}

Як і у попередньому прикладі, це і непотрібно, і неефективно. По-перше, видаліть стан і ефект:

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  // ✅ This is fine if getFilteredTodos() is not slow.
  const visibleTodos = getFilteredTodos(todos, filter);
  // ...
}

Зазвичай, цей код працює нормально! Але, можливо, getFilteredTodos() працює повільно або у вас багато todos. У такому випадку ви не хочете перераховувати getFilteredTodos(), якщо змінилася якась не пов'язана зі станом змінна типу newTodo.

Ви можете кешувати (або "запам'ятовувати") дорогі обчислення, обернувши їх у useMemo хук:

import { useMemo, useState } from 'react';

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  const visibleTodos = useMemo(() => {
    // ✅ Does not re-run unless todos or filter change
    return getFilteredTodos(todos, filter);
  }, [todos, filter]);
  // ...
}

Або, записаний в один рядок:

import { useMemo, useState } from 'react';

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  // ✅ Does not re-run getFilteredTodos() unless todos or filter change
  const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);
  // ...
}

Це повідомляє React, що ви не хочете повторного запуску внутрішньої функції, якщо не змінилися todos або filter. React запам'ятає значення, що повертається з getFilteredTodos() під час початкового рендерингу. Під час наступних рендерингів він перевірить, чи відрізняються todos або filter. Якщо вони такі ж, як і минулого разу, useMemo поверне останній збережений результат. Але якщо вони відрізняються, React знову викличе внутрішню функцію (і збереже її результат).

Функція, яку ви вкладаєте у useMemo, виконується під час рендерингу, тому це працює лише для чистих обчислень.

Як визначити, чи обчислення є дорогим?

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

console.time('filter array');
const visibleTodos = getFilteredTodos(todos, filter);
console.timeEnd('filter array');

Виконайте взаємодію, яку ви вимірюєте (наприклад, введення даних). Після цього у вашій консолі ви побачите записи на кшталт filter array: 0.15ms. Якщо загальний час, зафіксований у логах, складає значну цифру (скажімо, 1ms або більше), може мати сенс запам'ятати це обчислення. В якості експерименту ви можете обгорнути обчислення у useMemo, щоб перевірити, чи зменшився загальний час, записаний для цієї взаємодії, чи ні:

console.time('filter array');
const visibleTodos = useMemo(() => {
  return getFilteredTodos(todos, filter); // Skipped if todos and filter haven't changed
}, [todos, filter]);
console.timeEnd('filter array');

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

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

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

Скидання всього стану при зміні пропсів

Цей компонент ProfilePage отримує проп userId. Сторінка містить вхідний коментар, і ви використовуєте змінну стану comment для зберігання його значення. Одного дня ви помітили проблему: коли ви переходите від одного профілю до іншого, стан comment не скидається. Як наслідок, легко випадково залишити коментар у профілі не того користувача. Щоб виправити цю проблему, потрібно очищати змінну стану comment щоразу, коли змінюється userId:

export default function ProfilePage({ userId }) {
  const [comment, setComment] = useState('');

  // 🔴 Avoid: Resetting state on prop change in an Effect
  useEffect(() => {
    setComment('');
  }, [userId]);
  // ...
}

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

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

export default function ProfilePage({ userId }) {
  return (
    <Profile
      userId={userId}
      key={userId}
    />
  );
}

function Profile({ userId }) {
  // ✅ This and any other state below will reset on key change automatically
  const [comment, setComment] = useState('');
  // ...
}

Зазвичай React зберігає стан, коли той самий компонент рендериться в тому самому місці. Передаючи userId як ключ компоненту Profile, ви просите React розглядати два компоненти Profile з різними userId як два різні компоненти, які не повинні мати спільного стану. Щоразу, коли ключ (який ви встановили на userId) змінюється, React перестворює DOM і скидає стан компонента Profile та всіх його нащадків. Тепер поле коментаря буде очищуватися автоматично при переході між профілями.

Зверніть увагу, що у цьому прикладі лише зовнішній компонент ProfilePage експортується і є видимим для інших файлів проекту. Компонентам, що рендерить ProfilePage, не потрібно передавати ключ до нього: вони передають userId як звичайний проп. Те, що ProfilePage передає його як ключ до внутрішнього компонента Profile, є деталлю реалізації.

Коригування деякого стану при зміні пропсів

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

Цей List компонент отримує список елементів як проп, і зберігає вибраний елемент у змінній стану selection. Ви хочете скидати selection до null щоразу, коли проп items отримує інший масив:

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);

  // 🔴 Avoid: Adjusting state on prop change in an Effect
  useEffect(() => {
    setSelection(null);
  }, [items]);
  // ...
}

Це теж не ідеально. Кожного разу, коли items змінюється, List та його дочірні компоненти спочатку рендеритимуться із застарілим значенням selection. Потім React оновить DOM і запустить ефекти. Нарешті, виклик setSelection(null) викличе ще один повторний рендеринг List та його дочірніх компонентів, перезапустивши весь процес заново.

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

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);

  // Better: Adjust the state while rendering
  const [prevItems, setPrevItems] = useState(items);
  if (items !== prevItems) {
    setPrevItems(items);
    setSelection(null);
  }
  // ...
}

Зберігання інформації з попередніх візуалізацій у такий спосіб може бути складним для розуміння, але це краще, ніж оновлювати той самий стан у ефекті. У наведеному вище прикладі setSelection викликається безпосередньо під час рендерингу. React повторно відрендерить List одразу після того, як він вийде з оператором return. React ще не відрендерив дочірні елементи List і не оновив DOM, тому це дозволяє дочірнім елементам List пропустити відображення застарілого selection value.

Коли ви оновлюєте компонент під час рендерингу, React відкидає повернутий JSX і негайно повторює рендеринг. Щоб уникнути дуже повільних каскадних повторних спроб, React дозволяє вам оновлювати стан лише того ж самого компонента під час рендерингу. Якщо ви оновите стан іншого компонента під час рендерингу, ви побачите помилку. Умова на кшталт items !== prevItems необхідна для уникнення циклів. Ви можете змінювати стан таким чином, але будь-які інші побічні ефекти (наприклад, зміна DOM або встановлення таймаутів) повинні залишатися в обробниках подій або ефектах, щоб зберігати компоненти чистими.

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

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selectedId, setSelectedId] = useState(null);
  // ✅ Best: Calculate everything during rendering
  const selection = items.find(item => item.id === selectedId) ?? null;
  // ...
}

Тепер взагалі не потрібно "підлаштовувати" стан. Якщо елемент з вибраним ID є у списку, він залишається вибраним. Якщо ні, то вибір, обчислений під час рендерингу, буде null, оскільки не було знайдено відповідного елемента. Така поведінка відрізняється, але, можливо, є кращою, оскільки більшість змін до items зберігають виділення.

Спільне використання логіки між обробниками подій

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

function ProductPage({ product, addToCart }) {
  // 🔴 Avoid: Event-specific logic inside an Effect
  useEffect(() => {
    if (product.isInCart) {
      showNotification(`Added ${product.name} to the shopping cart!`);
    }
  }, [product]);

  function handleBuyClick() {
    addToCart(product);
  }

  function handleCheckoutClick() {
    addToCart(product);
    navigateTo('/checkout');
  }
  // ...
}

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

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

function ProductPage({ product, addToCart }) {
  // ✅ Good: Event-specific logic is called from event handlers
  function buyProduct() {
    addToCart(product);
    showNotification(`Added ${product.name} to the shopping cart!`);
  }

  function handleBuyClick() {
    buyProduct();
  }

  function handleCheckoutClick() {
    buyProduct();
    navigateTo('/checkout');
  }
  // ...
}

Це водноча видаляє непотрібний ефект і виправляє проблему.

Відправлення POST-запиту

Цей Form компонент надсилає два типи POST-запитів. Він надсилає аналітичну подію, коли монтується. Коли ви заповните форму і натиснете кнопку "Відправити", він надішле POST-запит у кінцеву точку /api/register:

function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  // ✅ Good: This logic should run because the component was displayed
  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  // 🔴 Avoid: Event-specific logic inside an Effect
  const [jsonToSubmit, setJsonToSubmit] = useState(null);
  useEffect(() => {
    if (jsonToSubmit !== null) {
      post('/api/register', jsonToSubmit);
    }
  }, [jsonToSubmit]);

  function handleSubmit(e) {
    e.preventDefault();
    setJsonToSubmit({ firstName, lastName });
  }
  // ...
}

Застосуємо ті самі критерії, що й у попередньому прикладі.

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

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

function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  // ✅ Good: This logic runs because the component was displayed
  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  function handleSubmit(e) {
    e.preventDefault();
    // ✅ Good: Event-specific logic is in the event handler
    post('/api/register', { firstName, lastName });
  }
  // ...
}

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

Ланцюжки обчислень

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

function Game() {
  const [card, setCard] = useState(null);
  const [goldCardCount, setGoldCardCount] = useState(0);
  const [round, setRound] = useState(1);
  const [isGameOver, setIsGameOver] = useState(false);

  // 🔴 Avoid: Chains of Effects that adjust the state solely to trigger each other
  useEffect(() => {
    if (card !== null && card.gold) {
      setGoldCardCount(c => c + 1);
    }
  }, [card]);

  useEffect(() => {
    if (goldCardCount > 3) {
      setRound(r => r + 1)
      setGoldCardCount(0);
    }
  }, [goldCardCount]);

  useEffect(() => {
    if (round > 5) {
      setIsGameOver(true);
    }
  }, [round]);

  useEffect(() => {
    alert('Good game!');
  }, [isGameOver]);

  function handlePlaceCard(nextCard) {
    if (isGameOver) {
      throw Error('Game already ended.');
    } else {
      setCard(nextCard);
    }
  }

  // ...

У цьому коді є дві проблеми.

Єдина проблема полягає у тому, що це дуже неефективно: компонент (і його дочірні елементи) доводиться перемальовувати між кожним викликом set у ланцюжку. У наведеному вище прикладі, у найгіршому випадку (setCard → render → setGoldCardCount → render → setRound → render → setIsGameOver → render) відбувається три непотрібних перерендери дерева нижче.

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

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

function Game() {
  const [card, setCard] = useState(null);
  const [goldCardCount, setGoldCardCount] = useState(0);
  const [round, setRound] = useState(1);

  // ✅ Calculate what you can during rendering
  const isGameOver = round > 5;

  function handlePlaceCard(nextCard) {
    if (isGameOver) {
      throw Error('Game already ended.');
    }

    // ✅ Calculate all the next state in the event handler
    setCard(nextCard);
    if (nextCard.gold) {
      if (goldCardCount <= 3) {
        setGoldCardCount(goldCardCount + 1);
      } else {
        setGoldCardCount(0);
        setRound(round + 1);
        if (round === 5) {
          alert('Good game!');
        }
      }
    }
  }

  // ...

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

Пам'ятайте, що всередині обробників подій стан поводиться як знімок. Наприклад, навіть після виклику setRound(round + 1) змінна round відображатиме значення на момент натискання кнопки користувачем. Якщо вам потрібно використати для обчислень наступне значення, визначте його вручну як const nextRound = round + 1.

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

Ініціалізація програми

Деяка логіка повинна виконуватися лише один раз під час завантаження застосунку.

У вас може виникнути спокуса помістити його в ефект у компоненті верхнього рівня:

function App() {
  // 🔴 Avoid: Effects with logic that should only ever run once
  useEffect(() => {
    loadDataFromLocalStorage();
    checkAuthToken();
  }, []);
  // ...
}

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

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

let didInit = false;

function App() {
  useEffect(() => {
    if (!didInit) {
      didInit = true;
      // ✅ Only runs once per app load
      loadDataFromLocalStorage();
      checkAuthToken();
    }
  }, []);
  // ...
}

Ви також можете запустити його під час ініціалізації модуля та перед рендерингом програми:

if (typeof window !== 'undefined') { // Check if we're running in the browser.
   // ✅ Only runs once per app load
  checkAuthToken();
  loadDataFromLocalStorage();
}

function App() {
  // ...
}

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

Сповіщення батьківських компонентів про зміни стану

Припустимо, ви пишете компонент Toggle з внутрішнім станом isOn, який може бути або true або false. Існує декілька різних способів перемикання (клацання або перетягування). Якщо ви хочете повідомити батьківський компонент про зміну внутрішнього стану Toggle, ви можете створити подію onChange і викликати її з ефекту:

function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);

  // 🔴 Avoid: The onChange handler runs too late
  useEffect(() => {
    onChange(isOn);
  }, [isOn, onChange])

  function handleClick() {
    setIsOn(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      setIsOn(true);
    } else {
      setIsOn(false);
    }
  }

  // ...
}

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

Видалити ефект і натомість оновити стан обох компонентів у тому самому обробнику події:

function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);

  function updateToggle(nextIsOn) {
    // ✅ Good: Perform all updates during the event that caused them
    setIsOn(nextIsOn);
    onChange(nextIsOn);
  }

  function handleClick() {
    updateToggle(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      updateToggle(true);
    } else {
      updateToggle(false);
    }
  }

  // ...
}

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

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

// ✅ Also good: the component is fully controlled by its parent
function Toggle({ isOn, onChange }) {
  function handleClick() {
    onChange(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      onChange(true);
    } else {
      onChange(false);
    }
  }

  // ...
}

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

Передача даних до батька

Цей компонент Child отримує деякі дані, а потім передає їх компоненту Parent в ефекті:

function Parent() {
  const [data, setData] = useState(null);
  // ...
  return <Child onFetched={setData} />;
}

function Child({ onFetched }) {
  const data = useSomeAPI();
  // 🔴 Avoid: Passing data to the parent in an Effect
  useEffect(() => {
    if (data) {
      onFetched(data);
    }
  }, [onFetched, data]);
  // ...
}

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

function Parent() {
  const data = useSomeAPI();
  // ...
  // ✅ Good: Passing data down to the child
  return <Child data={data} />;
}

function Child({ data }) {
  // ...
}

Цей спосіб простіший і робить потік даних передбачуваним: дані стікають від батьківського об'єкта до дочірнього.

Підписка на зовнішнє сховище

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

function useOnlineStatus() {
  // Not ideal: Manual store subscription in an Effect
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    function updateState() {
      setIsOnline(navigator.onLine);
    }

    updateState();

    window.addEventListener('online', updateState);
    window.addEventListener('offline', updateState);
    return () => {
      window.removeEventListener('online', updateState);
      window.removeEventListener('offline', updateState);
    };
  }, []);
  return isOnline;
}

function ChatIndicator() {
  const isOnline = useOnlineStatus();
  // ...
}

Тут компонент підписується на зовнішнє сховище даних (у цьому випадку на API браузера navigator.onLine). Оскільки цього API не існує на сервері (тому його не можна використовувати для початкового HTML), спочатку стан встановлюється у true. Щоразу, коли значення цього сховища даних змінюється у браузері, компонент оновлює свій стан.

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

function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

function useOnlineStatus() {
  // ✅ Good: Subscribing to an external store with a built-in Hook
  return useSyncExternalStore(
    subscribe, // React won't resubscribe for as long as you pass the same function
    () => navigator.onLine, // How to get the value on the client
    () => true // How to get the value on the server
  );
}

function ChatIndicator() {
  const isOnline = useOnlineStatus();
  // ...
}

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

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

Багато програм використовують ефекти для запуску збору даних. Досить поширеним є написання ефекту збору даних на кшталт:

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [page, setPage] = useState(1);

  useEffect(() => {
    // 🔴 Avoid: Fetching without cleanup logic
    fetchResults(query, page).then(json => {
      setResults(json);
    });
  }, [query, page]);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}

Вам не потрібно переносити цю вибірку до обробника подій.

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

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

Однак у наведеному вище коді є помилка. Уявіть, що ви швидко вводите "hello". Тоді запит зміниться з "h" на "he", "hel", "hell" і "hello". Це запустить окремі вибірки, але немає жодних гарантій щодо того, у якому порядку будуть отримані відповіді. Наприклад, відповідь "hell" може надійти після відповіді "hello". Оскільки setResults() буде викликано останнім, ви отримаєте неправильні результати пошуку. Це називається "стан гонитви": два різні запити "змагалися" один з одним і прийшли в іншому порядку, ніж ви очікували.

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

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [page, setPage] = useState(1);
  useEffect(() => {
    let ignore = false;
    fetchResults(query, page).then(json => {
      if (!ignore) {
        setResults(json);
      }
    });
    return () => {
      ignore = true;
    };
  }, [query, page]);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}

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

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

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

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

function SearchResults({ query }) {
  const [page, setPage] = useState(1);
  const params = new URLSearchParams({ query, page });
  const results = useData(`/api/search?${params}`);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}

function useData(url) {
  const [data, setData] = useState(null);
  useEffect(() => {
    let ignore = false;
    fetch(url)
      .then(response => response.json())
      .then(json => {
        if (!ignore) {
          setData(json);
        }
      });
    return () => {
      ignore = true;
    };
  }, [url]);
  return data;
}

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

Взагалі, коли вам доводиться вдаватися до написання ефектів, слідкуйте за тим, коли ви можете витягнути частину функціональності у власний хук з більш декларативним і спеціально створеним API, як useData вище. Чим менше необроблених викликів useEffect у ваших компонентах, тим легше вам буде підтримувати вашу програму.

  • Якщо ви можете обчислити щось під час рендерингу, вам не потрібен ефект.
  • Для кешування дорогих обчислень додайте useMemo замість useEffect.
  • Щоб скинути стан всього дерева компонентів, передайте йому інший ключ.
  • Щоб скинути певний біт стану у відповідь на зміну пропсів, встановіть його під час рендерингу.
  • Код, який запускається через те, що компонент було відображено, має бути у Effects, решта - у events.
  • Якщо вам потрібно оновити стан декількох компонентів, краще зробити це під час однієї події.
  • При спробі синхронізувати змінні стану у різних компонентах, розгляньте можливість підняття стану вгору.
  • Ви можете отримати дані за допомогою ефектів, але вам слід реалізувати очищення, щоб уникнути гонитви.

перетворити дані без ефектів

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

Спростіть цей компонент, видаливши всі непотрібні стани та ефекти.

import { useState, useEffect } from 'react';
import { initialTodos, createTodo } from './todos.js';

export default function TodoList() {
  const [todos, setTodos] = useState(initialTodos);
  const [showActive, setShowActive] = useState(false);
  const [activeTodos, setActiveTodos] = useState([]);
  const [visibleTodos, setVisibleTodos] = useState([]);
  const [footer, setFooter] = useState(null);

  useEffect(() => {
    setActiveTodos(todos.filter(todo => !todo.completed));
  }, [todos]);

  useEffect(() => {
    setVisibleTodos(showActive ? activeTodos : todos);
  }, [showActive, todos, activeTodos]);

  useEffect(() => {
    setFooter(
      <footer>
        {activeTodos.length} todos left
      </footer>
    );
  }, [activeTodos]);

  return (
    <>
      <label>
        <input
          type="checkbox"
          checked={showActive}
          onChange={e => setShowActive(e.target.checked)}
        />
        Show only active todos
      </label>
      <NewTodo onAdd={newTodo => setTodos([...todos, newTodo])} />
      <ul>
        {visibleTodos.map(todo => (
          <li key={todo.id}>
            {todo.completed ? <s>{todo.text}</s> : todo.text}
          </li>
        ))}
      </ul>
      {footer}
    </>
  );
}

function NewTodo({ onAdd }) {
  const [text, setText] = useState('');

  function handleAddClick() {
    setText('');
    onAdd(createTodo(text));
  }

  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={handleAddClick}>
        Add
      </button>
    </>
  );
}
let nextId = 0;

export function createTodo(text, completed = false) {
  return {
    id: nextId++,
    text,
    completed
  };
}

export const initialTodos = [
  createTodo('Get apples', true),
  createTodo('Get oranges', true),
  createTodo('Get carrots'),
];
label { display: block; }
input { margin-top: 10px; }

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

У цьому прикладі є лише дві основні частини стану: список todos та змінна стану showActive, яка показує, чи встановлено позначку. Всі інші змінні стану є надлишковими і можуть бути обчислені під час рендерингу. Сюди входить footer, який ви можете перемістити безпосередньо у навколишній JSX.

Ваш результат має виглядати так:

import { useState } from 'react';
import { initialTodos, createTodo } from './todos.js';

export default function TodoList() {
  const [todos, setTodos] = useState(initialTodos);
  const [showActive, setShowActive] = useState(false);
  const activeTodos = todos.filter(todo => !todo.completed);
  const visibleTodos = showActive ? activeTodos : todos;

  return (
    <>
      <label>
        <input
          type="checkbox"
          checked={showActive}
          onChange={e => setShowActive(e.target.checked)}
        />
        Show only active todos
      </label>
      <NewTodo onAdd={newTodo => setTodos([...todos, newTodo])} />
      <ul>
        {visibleTodos.map(todo => (
          <li key={todo.id}>
            {todo.completed ? <s>{todo.text}</s> : todo.text}
          </li>
        ))}
      </ul>
      <footer>
        {activeTodos.length} todos left
      </footer>
    </>
  );
}

function NewTodo({ onAdd }) {
  const [text, setText] = useState('');

  function handleAddClick() {
    setText('');
    onAdd(createTodo(text));
  }

  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={handleAddClick}>
        Add
      </button>
    </>
  );
}
let nextId = 0;

export function createTodo(text, completed = false) {
  return {
    id: nextId++,
    text,
    completed
  };
}

export const initialTodos = [
  createTodo('Get apples', true),
  createTodo('Get oranges', true),
  createTodo('Get carrots'),
];
label { display: block; }
input { margin-top: 10px; }

Кешування обчислень без ефектів

У цьому прикладі фільтрацію todos було виділено в окрему функцію з назвою getVisibleTodos(). Ця функція містить виклик console.log(), який допоможе вам помітити, коли вона викликається. Увімкніть опцію "Показувати лише активні todos" і помітьте, що вона призводить до повторного запуску getVisibleTodos(). Це очікувано, оскільки видимі справи змінюються, коли ви перемикаєте, які з них показувати.

Ваше завдання полягає у видаленні ефекту, який перераховує список visibleTodos у компоненті TodoList. Однак вам потрібно переконатися, що getVisibleTodos() не повторно запускає , а не (і не друкує жодних журналів), коли ви вводите дані.

Одним з рішень є додавання виклику useMemo для кешування видимих справ. Існує також інше, менш очевидне рішення.

import { useState, useEffect } from 'react';
import { initialTodos, createTodo, getVisibleTodos } from './todos.js';

export default function TodoList() {
  const [todos, setTodos] = useState(initialTodos);
  const [showActive, setShowActive] = useState(false);
  const [text, setText] = useState('');
  const [visibleTodos, setVisibleTodos] = useState([]);

  useEffect(() => {
    setVisibleTodos(getVisibleTodos(todos, showActive));
  }, [todos, showActive]);

  function handleAddClick() {
    setText('');
    setTodos([...todos, createTodo(text)]);
  }

  return (
    <>
      <label>
        <input
          type="checkbox"
          checked={showActive}
          onChange={e => setShowActive(e.target.checked)}
        />
        Show only active todos
      </label>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={handleAddClick}>
        Add
      </button>
      <ul>
        {visibleTodos.map(todo => (
          <li key={todo.id}>
            {todo.completed ? <s>{todo.text}</s> : todo.text}
          </li>
        ))}
      </ul>
    </>
  );
}
let nextId = 0;
let calls = 0;

export function getVisibleTodos(todos, showActive) {
  console.log(`getVisibleTodos() was called ${++calls} times`);
  const activeTodos = todos.filter(todo => !todo.completed);
  const visibleTodos = showActive ? activeTodos : todos;
  return visibleTodos;
}

export function createTodo(text, completed = false) {
  return {
    id: nextId++,
    text,
    completed
  };
}

export const initialTodos = [
  createTodo('Get apples', true),
  createTodo('Get oranges', true),
  createTodo('Get carrots'),
];
label { display: block; }
input { margin-top: 10px; }

Видалити змінну стану та ефект, а натомість додати виклик useMemo для кешування результату виклику getVisibleTodos():

import { useState, useMemo } from 'react';
import { initialTodos, createTodo, getVisibleTodos } from './todos.js';

export default function TodoList() {
  const [todos, setTodos] = useState(initialTodos);
  const [showActive, setShowActive] = useState(false);
  const [text, setText] = useState('');
  const visibleTodos = useMemo(
    () => getVisibleTodos(todos, showActive),
    [todos, showActive]
  );

  function handleAddClick() {
    setText('');
    setTodos([...todos, createTodo(text)]);
  }

  return (
    <>
      <label>
        <input
          type="checkbox"
          checked={showActive}
          onChange={e => setShowActive(e.target.checked)}
        />
        Show only active todos
      </label>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={handleAddClick}>
        Add
      </button>
      <ul>
        {visibleTodos.map(todo => (
          <li key={todo.id}>
            {todo.completed ? <s>{todo.text}</s> : todo.text}
          </li>
        ))}
      </ul>
    </>
  );
}
let nextId = 0;
let calls = 0;

export function getVisibleTodos(todos, showActive) {
  console.log(`getVisibleTodos() was called ${++calls} times`);
  const activeTodos = todos.filter(todo => !todo.completed);
  const visibleTodos = showActive ? activeTodos : todos;
  return visibleTodos;
}

export function createTodo(text, completed = false) {
  return {
    id: nextId++,
    text,
    completed
  };
}

export const initialTodos = [
  createTodo('Get apples', true),
  createTodo('Get oranges', true),
  createTodo('Get carrots'),
];
label { display: block; }
input { margin-top: 10px; }

З цією зміною getVisibleTodos() буде викликано лише у разі зміни todos або showActive. Введення на вході лише змінює змінну стану text, тому не викликає виклик getVisibleTodos().

Існує також інше рішення, яке не потребує useMemo. Оскільки змінна стану text не може впливати на список справ, ви можете виділити форму NewTodo в окремий компонент, а змінну стану text перемістити всередину нього:

import { useState, useMemo } from 'react';
import { initialTodos, createTodo, getVisibleTodos } from './todos.js';

export default function TodoList() {
  const [todos, setTodos] = useState(initialTodos);
  const [showActive, setShowActive] = useState(false);
  const visibleTodos = getVisibleTodos(todos, showActive);

  return (
    <>
      <label>
        <input
          type="checkbox"
          checked={showActive}
          onChange={e => setShowActive(e.target.checked)}
        />
        Show only active todos
      </label>
      <NewTodo onAdd={newTodo => setTodos([...todos, newTodo])} />
      <ul>
        {visibleTodos.map(todo => (
          <li key={todo.id}>
            {todo.completed ? <s>{todo.text}</s> : todo.text}
          </li>
        ))}
      </ul>
    </>
  );
}

function NewTodo({ onAdd }) {
  const [text, setText] = useState('');

  function handleAddClick() {
    setText('');
    onAdd(createTodo(text));
  }

  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={handleAddClick}>
        Add
      </button>
    </>
  );
}
let nextId = 0;
let calls = 0;

export function getVisibleTodos(todos, showActive) {
  console.log(`getVisibleTodos() was called ${++calls} times`);
  const activeTodos = todos.filter(todo => !todo.completed);
  const visibleTodos = showActive ? activeTodos : todos;
  return visibleTodos;
}

export function createTodo(text, completed = false) {
  return {
    id: nextId++,
    text,
    completed
  };
}

export const initialTodos = [
  createTodo('Get apples', true),
  createTodo('Get oranges', true),
  createTodo('Get carrots'),
];
label { display: block; }
input { margin-top: 10px; }

Цей підхід також відповідає вимогам. При введенні даних оновлюється лише змінна стану text. Оскільки змінна стану text знаходиться у дочірньому компоненті NewTodo, батьківський компонент TodoList не буде повторно рендеритись. Ось чому getVisibleTodos() не буде викликано під час введення. (Його все одно буде викликано, якщо TodoList буде повторно відрендерено з іншої причини).

Скинути стан без ефектів

Цей компонент EditContact отримує контактний об'єкт у формі { id, name, email } як проп savedContact. Спробуйте відредагувати поля введення імені та email. Коли ви натиснете кнопку Зберегти, кнопка контакту над формою оновиться до відредагованого імені. Коли ви натискаєте "Скинути", всі зміни у формі будуть скинуті. Пограйтеся з цим інтерфейсом, щоб зрозуміти, як він працює.

Коли ви обираєте контакт за допомогою кнопок вгорі, форма перезавантажується, щоб відобразити дані цього контакту. Це відбувається за допомогою ефекту всередині EditContact.js. Видаліть цей ефект. Знайдіть інший спосіб скидати форму при її зміні savedContact.id.

import { useState } from 'react';
import ContactList from './ContactList.js';
import EditContact from './EditContact.js';

export default function ContactManager() {
  const [
    contacts,
    setContacts
  ] = useState(initialContacts);
  const [
    selectedId,
    setSelectedId
  ] = useState(0);
  const selectedContact = contacts.find(c =>
    c.id === selectedId
  );

  function handleSave(updatedData) {
    const nextContacts = contacts.map(c => {
      if (c.id === updatedData.id) {
        return updatedData;
      } else {
        return c;
      }
    });
    setContacts(nextContacts);
  }

  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedId={selectedId}
        onSelect={id => setSelectedId(id)}
      />
      <hr />
      <EditContact
        savedContact={selectedContact}
        onSave={handleSave}
      />
    </div>
  )
}

const initialContacts = [
  { id: 0, name: 'Taylor', email: '[email protected]' },
  { id: 1, name: 'Alice', email: '[email protected]' },
  { id: 2, name: 'Bob', email: '[email protected]' }
];
export default function ContactList({
  contacts,
  selectedId,
  onSelect
}) {
  return (
    <section>
      <ul>
        {contacts.map(contact =>
          <li key={contact.id}>
            <button onClick={() => {
              onSelect(contact.id);
            }}>
              {contact.id === selectedId ?
                <b>{contact.name}</b> :
                contact.name
              }
            </button>
          </li>
        )}
      </ul>
    </section>
  );
}
import { useState, useEffect } from 'react';

export default function EditContact({ savedContact, onSave }) {
  const [name, setName] = useState(savedContact.name);
  const [email, setEmail] = useState(savedContact.email);

  useEffect(() => {
    setName(savedContact.name);
    setEmail(savedContact.email);
  }, [savedContact]);

  return (
    <section>
      <label>
        Name:{' '}
        <input
          type="text"
          value={name}
          onChange={e => setName(e.target.value)}
        />
      </label>
      <label>
        Email:{' '}
        <input
          type="email"
          value={email}
          onChange={e => setEmail(e.target.value)}
        />
      </label>
      <button onClick={() => {
        const updatedData = {
          id: savedContact.id,
          name: name,
          email: email
        };
        onSave(updatedData);
      }}>
        Save
      </button>
      <button onClick={() => {
        setName(savedContact.name);
        setEmail(savedContact.email);
      }}>
        Reset
      </button>
    </section>
  );
}
ul, li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li { display: inline-block; }
li button {
  padding: 10px;
}
label {
  display: block;
  margin: 10px 0;
}
button {
  margin-right: 10px;
  margin-bottom: 10px;
}

Було б добре, якби був спосіб сказати React, що коли savedContact.id відрізняється, форма EditContact концептуально є формою іншого контакту і не повинна зберігати стан. Ви пригадуєте такий спосіб?

Розділіть компонент EditContact надвоє. Перенесіть весь стан форми у внутрішній компонент EditForm. Експортуйте зовнішній компонент EditContact і зробіть так, щоб він передав savedContact.id як ключ внутрішньому компоненту EditForm. У результаті внутрішній компонент EditForm скидає весь стан форми і перестворює DOM щоразу, коли ви вибираєте інший контакт.

import { useState } from 'react';
import ContactList from './ContactList.js';
import EditContact from './EditContact.js';

export default function ContactManager() {
  const [
    contacts,
    setContacts
  ] = useState(initialContacts);
  const [
    selectedId,
    setSelectedId
  ] = useState(0);
  const selectedContact = contacts.find(c =>
    c.id === selectedId
  );

  function handleSave(updatedData) {
    const nextContacts = contacts.map(c => {
      if (c.id === updatedData.id) {
        return updatedData;
      } else {
        return c;
      }
    });
    setContacts(nextContacts);
  }

  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedId={selectedId}
        onSelect={id => setSelectedId(id)}
      />
      <hr />
      <EditContact
        savedContact={selectedContact}
        onSave={handleSave}
      />
    </div>
  )
}

const initialContacts = [
  { id: 0, name: 'Taylor', email: '[email protected]' },
  { id: 1, name: 'Alice', email: '[email protected]' },
  { id: 2, name: 'Bob', email: '[email protected]' }
];
export default function ContactList({
  contacts,
  selectedId,
  onSelect
}) {
  return (
    <section>
      <ul>
        {contacts.map(contact =>
          <li key={contact.id}>
            <button onClick={() => {
              onSelect(contact.id);
            }}>
              {contact.id === selectedId ?
                <b>{contact.name}</b> :
                contact.name
              }
            </button>
          </li>
        )}
      </ul>
    </section>
  );
}
import { useState } from 'react';

export default function EditContact(props) {
  return (
    <EditForm
      {...props}
      key={props.savedContact.id}
    />
  );
}

function EditForm({ savedContact, onSave }) {
  const [name, setName] = useState(savedContact.name);
  const [email, setEmail] = useState(savedContact.email);

  return (
    <section>
      <label>
        Name:{' '}
        <input
          type="text"
          value={name}
          onChange={e => setName(e.target.value)}
        />
      </label>
      <label>
        Email:{' '}
        <input
          type="email"
          value={email}
          onChange={e => setEmail(e.target.value)}
        />
      </label>
      <button onClick={() => {
        const updatedData = {
          id: savedContact.id,
          name: name,
          email: email
        };
        onSave(updatedData);
      }}>
        Save
      </button>
      <button onClick={() => {
        setName(savedContact.name);
        setEmail(savedContact.email);
      }}>
        Reset
      </button>
    </section>
  );
}
ul, li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li { display: inline-block; }
li button {
  padding: 10px;
}
label {
  display: block;
  margin: 10px 0;
}
button {
  margin-right: 10px;
  margin-bottom: 10px;
}

Надіслати форму без ефектів

Цей компонент Form дозволяє відправити повідомлення другу. Коли ви надсилаєте форму, змінна стану showForm набуває значення false. Це викликає ефект sendMessage(message), який надсилає повідомлення (ви можете побачити його у консолі). Після відправлення повідомлення ви побачите діалогове вікно "Дякую" з кнопкою "Відкрити чат", яка дозволить вам повернутися до форми.

Користувачі вашої програми надсилають забагато повідомлень. Щоб трохи ускладнити спілкування у чаті, ви вирішили показати діалог "Дякую" спочатку , а не форму. Змініть змінну стану showForm, щоб ініціалізувати її зі значенням false замість true. Щойно ви зробите цю зміну, консоль покаже, що було надіслано порожнє повідомлення. Щось у цій логіці не так!

Яка основна причина цієї проблеми? І як її можна виправити?

Чи потрібно надсилати повідомлення тому що користувач побачив діалогове вікно "Дякую"? Чи навпаки?

import { useState, useEffect } from 'react';

export default function Form() {
  const [showForm, setShowForm] = useState(true);
  const [message, setMessage] = useState('');

  useEffect(() => {
    if (!showForm) {
      sendMessage(message);
    }
  }, [showForm, message]);

  function handleSubmit(e) {
    e.preventDefault();
    setShowForm(false);
  }

  if (!showForm) {
    return (
      <>
        <h1>Thanks for using our services!</h1>
        <button onClick={() => {
          setMessage('');
          setShowForm(true);
        }}>
          Open chat
        </button>
      </>
    );
  }

  return (
    <form onSubmit={handleSubmit}>
      <textarea
        placeholder="Message"
        value={message}
        onChange={e => setMessage(e.target.value)}
      />
      <button type="submit" disabled={message === ''}>
        Send
      </button>
    </form>
  );
}

function sendMessage(message) {
  console.log('Sending message: ' + message);
}
label, textarea { margin-bottom: 10px; display: block; }

Змінна стану showForm визначає, чи відображати форму, чи діалогове вікно "Дякую". Однак ви не надсилаєте повідомлення, оскільки діалогове вікно "Дякую" було відображено . Ви хочете надіслати повідомлення, оскільки користувач надіслав форму. Видаліть оманливий ефект і перемістіть виклик sendMessage всередину обробника події handleSubmit:

import { useState, useEffect } from 'react';

export default function Form() {
  const [showForm, setShowForm] = useState(true);
  const [message, setMessage] = useState('');

  function handleSubmit(e) {
    e.preventDefault();
    setShowForm(false);
    sendMessage(message);
  }

  if (!showForm) {
    return (
      <>
        <h1>Thanks for using our services!</h1>
        <button onClick={() => {
          setMessage('');
          setShowForm(true);
        }}>
          Open chat
        </button>
      </>
    );
  }

  return (
    <form onSubmit={handleSubmit}>
      <textarea
        placeholder="Message"
        value={message}
        onChange={e => setMessage(e.target.value)}
      />
      <button type="submit" disabled={message === ''}>
        Send
      </button>
    </form>
  );
}

function sendMessage(message) {
  console.log('Sending message: ' + message);
}
label, textarea { margin-bottom: 10px; display: block; }

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