React Typescript

Можливо, вам не потрібен ефект: антипатерни та альтернативи

Глибокий аналіз типових помилок надмірного використання useEffect та правильні альтернативи через обчислювані значення, обробники подій та ключ key. Навчіться писати швидший та простіший код.

Можливо, вам не потрібен ефект: антипатерни та альтернативи

🔗 Офіційний референс: Можливо, вам не потрібен ефект (You Might Not Need an Effect)

🎯 Мета лекції

  • Розпізнавати типові антипатерни надмірного використання useEffect.
  • Опанувати техніки усунення зайвих ефектів через обчислення під час рендеру.
  • Навчитися скидати стан компонента через зміну ключа key без ефектів.
  • Застосувати правильні місця для мережевих запитів та оновлень стану.

🔑 Ключові терміни

  • Антипатерн (Anti-pattern): поширений спосіб написання коду, що здається правильним, але призводить до проблем.
  • Обчислювані значення (Derived Values): дані, що можуть бути отримані з існуючого стану без додаткового зберігання.
  • Ключ компонента (Component Key): спеціальний проп, зміна якого змушує React повністю перестворити компонент.
  • Каскадне оновлення (Cascading Update): ситуація, коли одна зміна стану викликає ефект, що змінює інший стан.

Чому надмірне використання ефектів є проблемою

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

Це призводить до кількох системних проблем:

  1. Зайві рендери: Ефект виконується після рендерингу, тому коли він змінює стан, компонент рендериться знову. Це створює каскад оновлень замість одного атомарного рендеру.
  2. Складність відстеження потоку даних: Коли стан змінюється у багатьох місцях (у обробниках подій та в ефектах), стає важко зрозуміти, чому інтерфейс виглядає саме так.
  3. Проблеми з продуктивністю: Кожен зайвий рендер — це додаткові обчислення, диференціювання віртуального DOM та можливі зміни реального DOM.
  4. Ускладнення тестування: Логіка, розмазана між обробниками подій та ефектами, важче тестується ізольовано.
Золоте правило useEffect: Якщо ваш код не виходить за межі React (не звертається до DOM, мережі, таймерів, сторонніх бібліотек), швидше за все, вам не потрібен useEffect. Спочатку спробуйте вирішити задачу через звичайну логіку рендерингу або обробники подій.

Антипатерн №1: Дублювання даних у стані через ефект

❌ Проблемний код: синхронізація через useEffect

Одна з найпоширеніших помилок — зберігати список у стані, а потім у useEffect обчислювати відфільтровану версію і зберігати її у другому стані:

import { useState, useEffect } from "react";

interface Task {
  id: number;
  title: string;
  completed: boolean;
}

export function TaskListBad() {
  const [tasks, setTasks] = useState<Task[]>([
    { id: 1, title: "Вивчити React", completed: true },
    { id: 2, title: "Опанувати TypeScript", completed: false },
    { id: 3, title: "Побудувати проєкт", completed: false },
  ]);

  // ❌ Погано: обчислюємо відфільтровані задачі у окремому стані через ефект
  const [activeTasks, setActiveTasks] = useState<Task[]>([]);

  useEffect(() => {
    const filtered = tasks.filter((task) => !task.completed);
    setActiveTasks(filtered);
  }, [tasks]); // Ефект запускається при кожній зміні tasks

  return (
    <div>
      <h3>Активні задачі ({activeTasks.length})</h3>
      <ul>
        {activeTasks.map((task) => (
          <li key={task.id}>{task.title}</li>
        ))}
      </ul>
    </div>
  );
}

Що тут не так:

  1. Два рендери замість одного: Коли tasks змінюється, React спочатку рендерить компонент з застарілим activeTasks, потім виконує ефект, оновлює activeTasks і запускає другий рендер.
  2. Дублювання джерела істини: Тепер activeTasks — це копія даних, що може стати неконсистентною, якщо забути оновити її у всіх місцях.
  3. Зайва складність: Лінтер вимагатиме додавання tasks у залежності, що створює відчуття «магічної синхронізації», хоча насправді це просте обчислення.

✅ Правильний підхід: обчислення під час рендеру

React надзвичайно швидкий у обчисленнях під час рендерингу. Фільтрація масиву з тисяч елементів займає мікросекунди — це не вимагає окремого стану:

import { useState } from "react";

interface Task {
  id: number;
  title: string;
  completed: boolean;
}

export function TaskListGood() {
  const [tasks, setTasks] = useState<Task[]>([
    { id: 1, title: "Вивчити React", completed: true },
    { id: 2, title: "Опанувати TypeScript", completed: false },
    { id: 3, title: "Побудувати проєкт", completed: false },
  ]);

  // ✅ Правильно: обчислюємо на льоту під час рендерингу
  const activeTasks = tasks.filter((task) => !task.completed);

  return (
    <div>
      <h3>Активні задачі ({activeTasks.length})</h3>
      <ul>
        {activeTasks.map((task) => (
          <li key={task.id}>{task.title}</li>
        ))}
      </ul>
    </div>
  );
}
React Порівняння: ефект vs обчислення
Файли проєкту
src
App.tsx
styles.css
localhost:3000

Збирання React застосунку…

Чому це краще:

  • Один рендер: Коли tasks змінюється, відбувається лише один рендер із актуальними activeTasks.
  • Одне джерело істини: tasks — єдине місце зберігання, всі інші дані обчислюються з нього.
  • Простіше тестування: Логіка фільтрації може бути витягнута у чисту функцію та протестована окремо.
Коли варто використовувати useMemo: Якщо фільтрація або трансформація займає помітний час (наприклад, сортування масиву з 10 000+ елементів за складним критерієм), оберніть обчислення у useMemo, щоб React кешував результат і пропускав повторні обчислення при рендерах, що не змінюють tasks:
const activeTasks = useMemo(() => {
  return tasks.filter(task => !task.completed);
}, [tasks]);
Проте у 90% випадків обчислення настільки швидкі, що useMemo не потрібен і додає лише зайву складність.

Антипатерн №2: Скидання стану при зміні пропса через ефект

❌ Проблемний код: синхронізація через useEffect

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

import { useState, useEffect } from "react";

interface User {
  id: number;
  name: string;
  email: string;
}

interface ProfileFormProps {
  userId: number;
  users: User[];
}

export function ProfileFormBad({ userId, users }: ProfileFormProps) {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");

  // ❌ Погано: синхронізуємо стан форми через ефект при зміні userId
  useEffect(() => {
    const user = users.find((u) => u.id === userId);
    if (user) {
      setName(user.name);
      setEmail(user.email);
    }
  }, [userId, users]);

  return (
    <form>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
      <button type="submit">Зберегти</button>
    </form>
  );
}

Проблеми цього підходу:

  1. Каскадний рендер із застарілими даними: Коли userId змінюється з 1 на 2, React спочатку рендерить форму зі старими значеннями name та email (ті, що були для userId: 1), потім виконує ефект, оновлює стан і запускає другий рендер із новими даними. Користувач бачить мигтіння застарілих даних.
  2. Незграбна логіка: Логіка ініціалізації форми розмазана між початковим станом та ефектом.
  3. Баг при редагуванні: Якщо користувач почав редагувати форму для userId: 1, потім перемкнувся на userId: 2 і повернувся до userId: 1, його незбережені зміни зникли, оскільки ефект скинув форму до вихідних даних.

✅ Правильний підхід: розділення компонентів через key

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

import { useState } from "react";

interface User {
  id: number;
  name: string;
  email: string;
}

interface ProfileFormProps {
  userId: number;
  users: User[];
}

function ProfileFormGood({ userId, users }: ProfileFormProps) {
  const user = users.find((u) => u.id === userId)!;
  
  // ✅ Початковий стан встановлюється один раз при монтуванні
  const [name, setName] = useState(user.name);
  const [email, setEmail] = useState(user.email);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    console.log("Збереження:", { userId, name, email });
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Ім'я:
        <input value={name} onChange={(e) => setName(e.target.value)} />
      </label>
      <label>
        Email:
        <input value={email} onChange={(e) => setEmail(e.target.value)} />
      </label>
      <button type="submit">Зберегти</button>
    </form>
  );
}

// Батьківський компонент передає key={userId}
export function ProfileEditor() {
  const [selectedUserId, setSelectedUserId] = useState(1);
  
  const users: User[] = [
    { id: 1, name: "Олена Коваленко", email: "olena@example.com" },
    { id: 2, name: "Андрій Мельник", email: "andriy@example.com" },
    { id: 3, name: "Марія Шевченко", email: "maria@example.com" },
  ];

  return (
    <div>
      <select
        value={selectedUserId}
        onChange={(e) => setSelectedUserId(Number(e.target.value))}
      >
        {users.map((user) => (
          <option key={user.id} value={user.id}>
            {user.name}
          </option>
        ))}
      </select>

      {/* ✅ Зміна key повністю перестворює компонент без useEffect! */}
      <ProfileFormGood key={selectedUserId} userId={selectedUserId} users={users} />
    </div>
  );
}
React Скидання форми через key
Файли проєкту
src
App.tsx
styles.css
localhost:3000

Збирання React застосунку…

Як це працює:

  1. Коли selectedUserId змінюється з 1 на 2, React бачить, що key пропс змінився.
  2. React видаляє старий екземпляр <ProfileFormGood key={1} ... /> з пам'яті, викликає його cleanup-ефекти (якщо є).
  3. React створює новий екземпляр <ProfileFormGood key={2} ... /> з нуля, з чистим початковим станом.
  4. Початковий стан нової форми ініціалізується з даних userId: 2.
Важливо розуміти:key — це не звичайний проп. React не передає його у компонент (якщо спробуєте зробити console.log(props.key), побачите undefined). Це спеціальна підказка для React, яка контролює ідентичність екземпляра компонента у віртуальному DOM.Коли key змінюється, React трактує це як інший компонент, а не як той самий компонент із новими пропсами.

Антипатерн №3: Мережеві запити у useEffect замість обробників подій

❌ Проблемний код: розділення логіки між обробником та ефектом

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

import { useState, useEffect } from "react";

export function ProductCardBad() {
  const [isInCart, setIsInCart] = useState(false);

  // ❌ Погано: робимо мережевий запит у ефекті
  useEffect(() => {
    if (isInCart) {
      fetch("/api/cart", {
        method: "POST",
        body: JSON.stringify({ productId: 123 }),
      });
    }
  }, [isInCart]);

  return (
    <button onClick={() => setIsInCart(true)}>
      Додати до кошика
    </button>
  );
}

Проблеми:

  1. Незрозумілий потік даних: Читаючи код, складно зрозуміти, що натискання кнопки викликає мережевий запит.
  2. Зайвий рендер: React спочатку рендерить компонент із isInCart: true, потім виконує ефект.
  3. Неправильна семантика: useEffect призначений для синхронізації з зовнішніми системами, а не для обробки дій користувача.

✅ Правильний підхід: мережевий запит безпосередньо в обробнику

Мережеві запити у відповідь на дії користувача належать до обробників подій, а не до ефектів:

import { useState } from "react";

export function ProductCardGood() {
  const [isInCart, setIsInCart] = useState(false);
  const [isLoading, setIsLoading] = useState(false);

  const handleAddToCart = async (): Promise<void> => {
    setIsLoading(true);

    try {
      await fetch("/api/cart", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ productId: 123 }),
      });

      setIsInCart(true);
    } catch (error) {
      console.error("Помилка додавання до кошика:", error);
      alert("Не вдалося додати товар до кошика");
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <button onClick={handleAddToCart} disabled={isInCart || isLoading}>
      {isLoading ? "Додавання..." : isInCart ? "У кошику ✓" : "Додати до кошика"}
    </button>
  );
}

Переваги:

  • Ясність: Вся логіка обробки кліку зосереджена в одному місці.
  • Один рендер: Стан оновлюється лише після успішної відповіді сервера.
  • Обробка помилок: Логіка помилок природно вписується у try-catch.

Антипатерн №4: Сповіщення батьківського компонента через ефект

❌ Проблемний код: виклик колбека в useEffect

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

import { useState, useEffect } from "react";

interface SearchInputProps {
  onChange: (value: string) => void;
}

export function SearchInputBad({ onChange }: SearchInputProps) {
  const [query, setQuery] = useState("");

  // ❌ Погано: викликаємо колбек у ефекті
  useEffect(() => {
    onChange(query);
  }, [query, onChange]);

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="Пошук..."
    />
  );
}

Проблеми:

  1. Каскадне оновлення: Спочатку SearchInputBad оновлює власний стан, рендериться, потім виконує ефект, який викликає onChange, що може змінити стан батьківського компонента і спричинити його рендер.
  2. Зайва складність: Лінтер вимагає onChange у залежностях, що може призвести до проблем, якщо батьківський компонент передає нову функцію при кожному рендері.
  3. Непотрібний ефект: Це не синхронізація з зовнішньою системою — це просто передача даних вгору.

✅ Правильний підхід: виклик колбека безпосередньо в обробнику

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

import { useState } from "react";

interface SearchInputProps {
  onChange: (value: string) => void;
}

export function SearchInputGood({ onChange }: SearchInputProps) {
  const [query, setQuery] = useState("");

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
    const newValue = e.target.value;
    setQuery(newValue);
    
    // ✅ Правильно: викликаємо колбек безпосередньо в обробнику
    onChange(newValue);
  };

  return (
    <input
      value={query}
      onChange={handleChange}
      placeholder="Пошук..."
    />
  );
}

Або ще простіше — взагалі не зберігати локальний стан:

interface SearchInputProps {
  value: string;
  onChange: (value: string) => void;
}

export function SearchInputSimple({ value, onChange }: SearchInputProps) {
  return (
    <input
      value={value}
      onChange={(e) => onChange(e.target.value)}
      placeholder="Пошук..."
    />
  );
}

Тепер батьківський компонент повністю контролює стан, а дочірній просто повідомляє про події. Це класичний патерн контрольованого компонента (controlled component).


Коли useEffect дійсно потрібен: контрольний список

✅ Використовуйте useEffect

  • Підписка на браузерні події: window.addEventListener
  • Мережеві запити при монтуванні: початкове завантаження даних з API
  • Інтеграція сторонніх бібліотек: ініціалізація карт, графіків, відеоплеєрів
  • Синхронізація з браузерним API: document.title, localStorage
  • WebSocket або Server-Sent Events: довготривалі з'єднання

❌ Не використовуйте useEffect

  • Трансформація даних для рендерингу: обчислюйте під час рендеру або через useMemo
  • Обробка подій користувача: мережеві запити належать до обробників подій
  • Скидання стану при зміні пропсів: використовуйте key або контрольовані компоненти
  • Сповіщення батьківського компонента: викликайте колбеки в обробниках подій

Практичні завдання


Підсумок: контрольний список перевірки useEffect

Перш ніж писати useEffect, задайте собі ці запитання:

Питання 1: Чи це синхронізація з зовнішньою системою?

Якщо ваш код не взаємодіє з DOM, мережею, таймерами, WebSocket або сторонніми бібліотеками, useEffect швидше за все не потрібен.

Приклад: Фільтрація списку — це обчислення, а не синхронізація.

Питання 2: Чи можу я обчислити це під час рендерингу?

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

Приклад: const total = items.reduce((sum, item) => sum + item.price, 0);

Питання 3: Чи це реакція на подію користувача?

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

Приклад: Відправка форми — це обробник onSubmit, а не useEffect.

Питання 4: Чи можу я використовувати key замість ефекту?

Якщо вам потрібно скинути стан компонента при зміні пропса, спробуйте спочатку змінити key — це простіше і швидше.

Приклад: <Form key={userId} ... /> автоматично скине форму без useEffect.

Пам'ятайте:useEffect — це не «хук для всього підряд». Це спеціалізований інструмент для синхронізації компонентів із зовнішніми системами. Якщо ви використовуєте його для координації стану всередині React, швидше за все, існує простіший спосіб.

Наступні кроки

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

  • Наступна лекція: «Життєвий цикл реактивних ефектів» — розглянемо, як React керує повторною синхронізацією, роботу лінтера exhaustive-deps та техніки відділення реактивної логіки від нереактивної.
  • Подальші теми: розділення подій та ефектів, видалення зайвих залежностей, кастомні хуки для інкапсуляції складної логіки ефектів.
Практична рекомендація: Проведіть аудит існуючого коду: знайдіть усі випадки використання useEffect і задайте собі чотири контрольні запитання з цієї лекції. Ви здивуєтеся, скільки ефектів можна усунути, зробивши код простішим, швидшим та зрозумілішим.
Copyright © 2026