Головна / Статті

Вибір правильного інструменту React: Derive, Handle, Fetch, Defer чи Effect

Посібник з прийняття рішень щодо заміни викликів reflexive useEffect на похідні значення, обробники подій, шар даних, useTransition, measured useMemo та API React 19.

Видавець: REACTAPP.TOP8 хв читання1896 слів

Джерела: Stop Using `useEffect` for Everything: 7 React Patterns That Make Your Code Simpler

Багато компонентів React збирають виклики useEffect для отримання даних, реагування на параметри, обчислення значень та підтримки узгодженості стану. Через кілька місяців ви відкриваєте файл та знаходите чотири ефекти, три значення похідного стану, два прапорці завантаження та баг, який ніхто не може пояснити. Цей посібник пропонує одне запитання, яке варто поставити перед написанням будь-якого хука: «Чи справді це потребує ефекту?», а також сім конкретних патернів для використання у випадку, коли відповідь — ні.

Похідні значення належать до функції render, а не до стану

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

const [filteredProducts, setFilteredProducts] = useState([]);

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

useEffect(() => {
  setFilteredProducts(
    products.filter(product =>
      product.name.toLowerCase().includes(query.toLowerCase())
    )
  );
}, [products, query]);

Це працює, але filteredProducts не є незалежним станом. Це чиста функція від значень, які ви вже маєте:

products
+
query
=
filteredProducts

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

const filteredProducts = products.filter(product =>
  product.name.toLowerCase().includes(query.toLowerCase())
);

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

const filteredProducts = useMemo(() => {
  return products.filter(product =>
    product.name.toLowerCase().includes(query.toLowerCase())
  );
}, [products, query]);

Memo — це деталь. Важливим є принцип: значення, отримане з props або стану, не потребує свого місця в стані.

Дії користувача мають знаходитися у обробниках подій

Інший паттерн передає клік через стан, щоб Effect міг на нього відреагувати. Спочатку — флаг:

const [shouldSave, setShouldSave] = useState(false);

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

useEffect(() => {
  if (shouldSave) {
    saveUser();
    setShouldSave(false);
  }
}, [shouldSave]);function handleSave() {
  setShouldSave(true);
}

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

function handleSave() {
  saveUser();
}

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

Click
  ↓
State update
  ↓
Render
  ↓
Effect
  ↓
saveUser()
  ↓
Another state update
  ↓
Render

У прямій версії є лише один крок:

Click
  ↓
saveUser()

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

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

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

const [name, setName] = useState('');

а ефект перезаписує цю копію щоразу, коли змінюється пропс:

useEffect(() => {
  setName(user.name);
}, [user]);

Тепер існує два джерела істини, і в будь-який момент потрібно визначити, яке з них є правильним:

user.name
     +
name

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

return <h2>{user.name}</h2>;

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

Server data
    ↓
Edit form
    ↓
User changes fields
    ↓
Save / Cancel

Тут локальний стан є правильним, оскільки проект справді є окремою величиною з власним життєвим циклом. Урок полягає не у тому, щоб «ніколи не копіювати пропси до стану», а у тому, щоб розуміти, чому ви створюєте друге джерело інформації. У випадку форми скидання проекту шляхом надання формі key, пов’язаного з ідентифікатором запису, часто є більш зручним, ніж синхронізація з Effect; наша стаття про заміну ефектів синхронізації стану детально розглядає цю техніку.

Нехай шар даних відповідає за отримання даних

Завантаження даних зазвичай розподіляється між різними Effectами. Все починається з одного запиту під час ініціалізації:

useEffect(() => {
  fetchUser();
}, []);

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

useEffect(() => {
  fetchOrders();
}, [userId]);useEffect(() => {
  fetchNotifications();
}, [userId]);useEffect(() => {
  fetchPermissions();
}, [userId]);

На цьому етапі компонент стає шаром оркестрації API. У більших додатках саме тут корисною виявляється спеціалізована система отримання даних. Залежно від вашої технологічної стеку це може бути:

  • TanStack Query
  • SWR
  • функції отримання даних вашої фреймворк-системи
  • Server Components, там де це доцільно

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

loading
error
data
refetch
cache
retry
stale data
race conditions

Шар даних обробляє все це за вас. Концептуально запит визначає, які дані потрібні та як їх отримати, а також повертає поточний статус:

const { data, isLoading, error } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId)
});

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

Позначте некритичні оновлення за допомогою useTransition

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

function handleChange(e) {
  setQuery(e.target.value);
}

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

const [inputValue, setInputValue] = useState('');
const [query, setQuery] = useState('');

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

const [isPending, startTransition] = useTransition();function handleChange(e) {
  const value = e.target.value;  setInputValue(value);  startTransition(() => {
    setQuery(value);
  });
}

Тепер React обробляє ці два оновлення по-різному. Введення даних вважається критичним:

Typing
↓
Urgent

тоді як процес фільтрації та переробки може бути перерваний чи відкладений:

Filtering/rendering
↓
Can wait

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

Вимірюйте перед тим, як зберігати в пам’яті

Протилежною помилкою є оптимізація інстинктивно. Вона часто починається з одного обчислення, яке зберігається в пам’яті:

const result = useMemo(() => {
  return calculateSomething(data);
}, [data]);

потім йде мапування, яке також зберігається в пам’яті:

const users = useMemo(() => {
  return data.map(...);
}, [data]);

далі — визначення стовпця, яке зберігається в пам’яті:

const columns = useMemo(() => [...], []);

та опції, які зберігаються в пам’яті:

const options = useMemo(() => [...], []);

Незабаром кожне значення в компоненті опиняється у «обгортці», проте ніхто так і не перевіряє, чи щось працює повільно. useMemo допомагає з енергозатратними операціями та стабільними посиланнями на елементи, збережені в пам’яті, але оптимізація має відбуватися після виконання циклу:

Problem
  ↓
Measure
  ↓
Find bottleneck
  ↓
Optimize
  ↓
Measure again

а не як швидкий шлях:

Component feels complicated
  ↓
Add useMemo

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

Використовуйте примітиви, додані в React 19

Кілька звичок з попередніх версій React тепер мають простіші альтернативи. Протягом багатьох років передача ref у власний компонент означала його обгортання за допомогою forwardRef. У React 19 функційні компоненти отримують ref як звичайний проп:

function CustomInput({ ref, ...props }) {
  return <input ref={ref} {...props} />;
}

Оптимістичний UI — ще один приклад. Замість того, щоб вручну додавати тимчасовий елемент у стан та пам’ятати про його подальше видалення чи заміну:

setTodos(prev => [
  ...prev,
  {
    id: 'temp',
    text,
    pending: true
  }
]);

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

const [optimisticTodos, addOptimisticTodo] = useOptimistic(
  todos,
  (state, todo) => [...state, todo]
);

Оптимістичне значення відображається під час виконання асинхронної дії та замінюється на справжній набір todos як тільки дія завершується, тому немає потреби очищати тимчасові елементи. Його слід викликати всередині дії чи переходу; крайні випадки, пов’язані з невдалими запитами, розглянуті у нашій статті про режими відката за допомогою useOptimistic.

Для ID, які пов’язують мітки з елементами введення задля доступності, використовуйте вбудований хук:

const id = useId();

замість того, щоб генерувати його випадково:

const id = `input-${Math.random()}`;

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

Чек-лист рішень перед додаванням хука

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

useEffect()
useMemo()
useCallback()
useRef()
useState()

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

Для похідних даних:

Calculate it.

Для дії користувача:

Handle the event.

Для синхронізації з чимось поза React, наприклад з підпискою, таймером чи не-React віджетом:

Consider useEffect.

Для читання даних з зовнішнього сховища:

Consider useSyncExternalStore or a state library.

Для оновлення, яке є важливим, але не терміновим:

Consider useTransition.

Для обчислень, які можуть бути ресурсоємними:

Measure first, then consider useMemo.

І коли React вже має примітив для виконання цієї задачі:

Use the primitive instead of rebuilding it yourself.

Основні висновки

  • Найздоровіші компоненти зазвичай мають найменшу кількість зайвих хуків, а не найбільшу.
  • Замініть запитання «Де я можу використати useEffect?» на «Чи справді цьому потрібен хук Effect?» У більшості випадків відповідь вказує на обчислення під час відображення, обробник подій або бібліотеку даних.
  • Дізнайтеся про проблему, яку вирішує кожен новий API React, перш ніж вивчати його синтаксис.
  • Іноді найефективнішою оптимізацією компонента є видалення хука, а не його додавання.