Головна / Статті / Припиніть синхронізацію стану за допомогою useEffect: безпечніший патерн у React

Припиніть синхронізацію стану за допомогою useEffect: безпечніший патерн у React

Дізнайтеся, чому використання useEffect для синхронізації похідного стану спричиняє ситуації змагання та зайве відрендування, та як замінити його на обчислення під час відрендування та використання атрибута key.

2523 слів

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

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

Ви досліджуєте кодову базу. Тут немає нічого незвичайного — жодного шару WebSocket, жодних робочих потоків, лише досить типовий формат відображення «головна частина — деталі» у React.

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

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

useEffect(() => {
  if (selectedUserId) {
    fetchUserData(selectedUserId).then((data) => {
      setUserProfile(data);
    });
  }
}, [selectedUserId]);

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

1. Хибна думка про життєвий цикл: чому розробники використовують useEffect за замовчуванням

Коли Hooks з’явилися у React 16.8, інженери з досвідом роботи з класовими компонентами часто розглядали useEffect як заміну для componentDidMount, componentDidUpdate та componentWillUnmount у єдиному API.

Це припущення згодом призвело до справжньої плутанини.

Компоненти класу сприяли імперативному стилю: коли змінювався проп, потрібно було вручну викликати this.setState() у функції componentDidUpdate, щоб перерахувати все, що залежало від нього.

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

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

Ось послідовність подій:

  1. React рендерує компонент, використовуючи нові пропи разом із все ще застарілим станом.
  • Цей результат відображення зберігається у DOM, і браузер його відрисовує.
  • Викликається ефект, який запускає setState().
  • React ставить у чергу друге відображення, яке відображає оновлений стан.
  • Розгляньмо цей приклад:

    function UserBillingSummary({
      plan,
      addonCount
    }: {
      plan: string;
      addonCount: number;
    }) {
      const [totalCost, setTotalCost] = useState(0);
    
      useEffect(() => {
        const base = plan === 'enterprise' ? 499 : 99;
        setTotalCost(base + addonCount * 25);
      }, [plan, addonCount]);  return <div>Total: ${totalCost} / month</div>;
    }
    

    Тут зовсім немає потреби у окремій змінні стану — totalCost можна повністю обчислити на основі plan та addonCount.

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

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

    2. Ефект доміно: ланцюги взаємозалежностей

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

    function AnalyticsFilters({
      organizationId
    }: {
      organizationId: string;
    }) {
      const [teams, setTeams] = useState<Team[]>([]);
      const [selectedTeamId, setSelectedTeamId] = useState<string>('');
      const [projects, setProjects] = useState<Project[]>([]);
      const [selectedProjectId, setSelectedProjectId] = useState<string>('');
    
      useEffect(() => {
        fetchTeams(organizationId).then((res) => {
          setTeams(res);
          setSelectedTeamId(res[0]?.id || '');
        });
      }, [organizationId]);  useEffect(() => {
        if (selectedTeamId) {
          fetchProjects(selectedTeamId).then((res) => {
            setProjects(res);
            setSelectedProjectId(res[0]?.id || '');
          });
        }
      }, [selectedTeamId]);  useEffect(() => {
        if (selectedProjectId) {
          logAnalyticsFilterChange(selectedProjectId);
        }
      }, [selectedProjectId]);  return (
        <div className="filter-bar">
          {/* Filter UI */}
        </div>
      );
    }
    

    Подивіться, що відбувається у момент зміни organizationId:

    1. React оновлює інтерфейс, використовуючи нове значення organizationId.
    2. Перший ефект отримує список команд, а потім оновлює як teams, так і selectedTeamId.
    3. React знову оновлює інтерфейс.
    4. Другий ефект помічає оновлене значення selectedTeamId та отримує відповідні проекти.
    5. React знову оновлює інтерфейс.
    6. Третій ефект бере до уваги нове значення selectedProjectId та фіксує цю зміну.

    Те, що починалося як оновлення одного параметра, тепер перетворилося на ланцюг змін стану та виконання ефектів.

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

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

    3. Привид асинхронних умов змагання

    Некеровані асинхронні виклики всередині useEffect є ще одним поширеним джерелом фантомних даних у односторінкових додатках.

    function TicketDetailView({
      ticketId
    }: {
      ticketId: string;
    }) {
      const [ticket, setTicket] = useState<TicketData | null>(null);
      const [loading, setLoading] = useState(true);
    
      useEffect(() => {
        setLoading(true);    api.getTicket(ticketId).then((data) => {
          setTicket(data);
          setLoading(false);
        });
      }, [ticketId]);  if (loading) return <div>Loading ticket...</div>;  return <TicketDetails ticket={ticket} />;
    }
    

    Ось послідовність подій, яка ламає цей компонент:

    1. Агент натискає на замовлення №101. Відправляється запит A.
    2. Перш ніж запит A буде оброблений, агент натискає на замовлення №102. Відправляється запит B.
    3. Запит B обробляється першим, тому у інтерфейсі тепер відображається замовлення №102.
    4. Нарешті запит A обробляється та викликає функцію setTicket(ticket101).
    5. У бічній панелі все ще підсвічується замовлення №102, але у вікні деталей тепер відображається замовлення №101.

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

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

    useEffect(() => {
      let isCurrent = true;
    
      setLoading(true);  api.getTicket(ticketId)
        .then((data) => {
          if (isCurrent) {
            setTicket(data);
            setLoading(false);
          }
        })
        .catch((err) => {
          if (isCurrent) {
            handleTicketError(err);
          }
        });  return () => {
        isCurrent = false;
      };
    }, [ticketId]);
    

    Кращим варіантом, якщо ваш клієнт API це підтримує, є AbortController. Замість простого ігнорування запізнілої відповіді можна скасувати запущений запит ще до того, як він завершиться.

    4. Краща альтернатива: похідний стан під час відображення

    Часто найпростішим рішенням проблем синхронізації є уникнення синхронізації стану з самого початку.

    У багатьох випадках, коли розробники інстинктивно використовують useState разом із useEffect, значення, яке вони намагаються зберегти, вже існує десь у поточних параметрах або стані батьківського елемента.

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

    Ось проблемна схема:

    function OrderSummary({
      items,
      discountCode
    }: OrderSummaryProps) {
      const [discountPercent, setDiscountPercent] = useState(0);
      const [subtotal, setSubtotal] = useState(0);
      const [finalTotal, setFinalTotal] = useState(0);
    
      useEffect(() => {
        const rawSum = items.reduce(
          (acc, item) => acc + item.price * item.quantity,
          0
        );    setSubtotal(rawSum);
      }, [items]);  useEffect(() => {
        const discount = calculateDiscount(discountCode);
        setDiscountPercent(discount);
      }, [discountCode]);  useEffect(() => {
        setFinalTotal(
          subtotal - subtotal * (discountPercent / 100)
        );
      }, [subtotal, discountPercent]);  return (
        <SummaryView
          subtotal={subtotal}
          total={finalTotal}
        />
      );
    }
    

    Тепер порівняйте це з версією, побудованою на похідному стані:

    function OrderSummary({
      items,
      discountCode
    }: OrderSummaryProps) {
      const subtotal = items.reduce(
        (acc, item) => acc + item.price * item.quantity,
        0
      );
    
      const discountPercent = calculateDiscount(discountCode);  const finalTotal =
        subtotal - subtotal * (discountPercent / 100);  return (
        <SummaryView
          subtotal={subtotal}
          total={finalTotal}
        />
      );
    }
    

    Контраст має значення. Не існує дублікатів стану, немає механізму, який би забезпечував синхронізацію значень, а також немає окна, в якому finalTotal міг би вийти з узгодження з subtotal та discountPercent.

    Самі пропси залишаються єдиним джерелом істини протягом усього процесу.

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

    const filteredTransactions = useMemo(() => {
      return rawTransactions.filter((tx) => {
        return (
          tx.amount >= minThreshold &&
          tx.category === activeCategory
        );
      });
    }, [rawTransactions, minThreshold, activeCategory]);
    

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

    5. Декларативне скидання стану за допомогою пропса key

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

    Зазвичай люди діють так:

    function EditUserModal({
      user
    }: {
      user: UserData;
    }) {
      const [name, setName] = useState(user.name);
      const [role, setRole] = useState(user.role);
    
      useEffect(() => {
        setName(user.name);
        setRole(user.role);
      }, [user.id]);  return (
        <form>
          <input
            value={name}
            onChange={(e) => setName(e.target.value)}
          />      <select
            value={role}
            onChange={(e) => setRole(e.target.value)}
          />
        </form>
      );
    }
    

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

    Ще гірше, це може мовчки перезаписати те, що користувач вводив, якщо нові дані надійдуть під час редагування.

    У React вже є вбудоване, декларативне рішення для цього: властивість key.

    У батьківському компоненті:

    function UserAdminPage() {
      const [selectedUser, setSelectedUser] =
        useState<UserData | null>(null);
    
      return (
        <div>
          <UserList onSelectUser={setSelectedUser} />      {selectedUser && (
            <EditUserForm
              key={selectedUser.id}
              initialUser={selectedUser}
            />
          )}
        </div>
      );
    }
    

    Локальний стан дочірнього компонента залишається простим, без потреби у синхронізації:

    function EditUserForm({
      initialUser
    }: {
      initialUser: UserData;
    }) {
      const [name, setName] = useState(initialUser.name);
      const [role, setRole] = useState(initialUser.role);
    
      return (
        <form>
          <input
            value={name}
            onChange={(e) => setName(e.target.value)}
          />      <select
            value={role}
            onChange={(e) => setRole(e.target.value)}
          />
        </form>
      );
    }
    

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

    Зовсім не потрібен ефект синхронізації.

    6. Де насправді має знаходитися код: обробники подій проти ефектів

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

    function CheckoutButton({
      orderId
    }: {
      orderId: string;
    }) {
      const [submitted, setSubmitted] = useState(false);
    
      useEffect(() => {
        if (submitted) {
          analytics.track('order_submitted', {
            orderId
          });      showToast('Order placed successfully!');
        }
      }, [submitted, orderId]);  return (
        <button onClick={() => setSubmitted(true)}>
          Place Order
        </button>
      );
    }
    

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

    function CheckoutButton({
      orderId
    }: {
      orderId: string;
    }) {
      const handlePlaceOrder = async () => {
        await submitOrderApi(orderId);
    
        analytics.track('order_submitted', {
          orderId
        });    showToast('Order placed successfully!');
      };  return (
        <button onClick={handlePlaceOrder}>
          Place Order
        </button>
      );
    }
    

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

    7. Коли useEffect дійсно є доцільним?

    Усе це не означає, що сам useEffect має недоліки.

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

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

    Це „щось поза React“ зазвичай належить до таких категорій:

    • Нативні API браузера, такі як window.addEventListener, IntersectionObserver або matchMedia
  • Бібліотеки від сторонніх розробників у імперативному стилі, такі як Mapbox, Chart.js чи SDK для відеоплеєрів
  • З’єднання в реальному часі, такі як WebSockets або Server-Sent Events
  • Пряма маніпуляція DOM, наприклад оновлення document.title
  • Відстеження ширини вікна браузера під час зміни розміру є гарним прикладом законномірного ефекту:

    function useWindowWidth() {
      const [width, setWidth] = useState(
        () => window.innerWidth
      );
    
      useEffect(() => {
        const handleResize = () => {
          setWidth(window.innerWidth);
        };    window.addEventListener(
          'resize',
          handleResize
        );    return () => {
          window.removeEventListener(
            'resize',
            handleResize
          );
        };
      }, []);  return width;
    }
    

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

    Саме для таких завдань і був створений useEffect.

    Архітектурні правила, яких я дотримуюся зараз

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

    Чотири основні принципи допомагають вирішити більшість проблем.

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

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

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

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

    Мета не в тому, щоб повністю позбутися useEffect у вашому коді.

    Мета — припинити використовувати його як неформальний механізм для передачі значень між різними елементами стану React.

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

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

    Пов’язана література

  • Увімкнення підтримки офлайн-режиму у веб-додатках за допомогою Service Workers — Дізнайтеся, як використовувати Service Workers та API Cache для миттєвого завантаження веб-сайту та його подальшої роботи навіть без підключення до Інтернету.
  • Пояснення процесу відображення у React: оновлення стану до пікселів екрана — Дізнайтеся, як фази відображення, узгодження та збереження даних у React пов’язані з процесами формування макету, нанесення кольорів та об’єднання елементів браузера для створення пікселів.
  • Усунення проблем конкурентних умов: чому дебаунсинг не може вирішити їх у інтерфейсах пошуку — Дізнайтеся, чому сам дебаунсинг не може запобігти тому, що застарілі відповіді API перезапишуть свіжий стан інтерфейсу, та ознайомтесь з чотирма практичними рішеннями для забезпечення правильної послідовності запитів.
  • Чому калебки useEffect у React ніколи не повинні бути асинхронними функціями — Дізнайтеся, чому повернення асинхронної функції з useEffect порушує принципи очищення в React, та ознайомтесь із чотирма правильними підходами для безпечної обробки асинхронної логіки.