Главная / Статьи / Прекратите синхронизацию состояния с useEffect: более безопасный паттерн в React

Прекратите синхронизацию состояния с useEffect: более безопасный паттерн в React

Узнайте, почему использование useEffect для синхронизации производного состояния приводит к конкурентным ситуациям и избыточной отрисовке, а также как заменить его на вычисление состояния во время отрисовки и использование атрибута key.

2523 слов

Как использование useEffect в качестве механизма синхронизации состояния приводит к циклам перерисовки, ситуациям конкуренции и видимым сбоям интерфейса.

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

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

Проводя тестирование локально, вы переключаетесь с одного аккаунта на другой. Первые девяносто девять переключений работают нормально. Затем, при ста попытке с включенным ограничением скорости сети, происходит визуальный сбой: на короткое время уровень тарифа ранее выбранного пользователя отображается в карточке профиля новоизбранного пользователя, прежде чем всё возвращается в норму.

Корень этой проблемы кроется в паттерне, который кажется совершенно безвредным:

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

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

1. Заблуждение о жизненном цикле: почему разработчики предпочитают useEffect

Когда в React 16.8 появились хуки, инженеры с опытом работы с классовыми компонентами часто рассматривали useEffect как замену для componentDidMount, componentDidUpdate и componentWillUnmount в виде единого интерфейса.

Это предположение впоследствии привело к настоящей путанице.

Компоненты классов способствовали использованию императивного стиля: когда менялся проп, необходимо было вручную вызывать 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, и ознакомьтесь с четырьмя правильными способами безопасной обработки асинхронной логики.