Как стать автором
Обновить

Решение проблемы с многократным запуском эффектов в React 18

Время на прочтение11 мин
Количество просмотров7K

Введение

В этой статье мы рассмотрим адаптацию компонентов React 18 к много кратному монтированию и повторному вызову эффектов с повторно используемым стоянием (Reusable State). Под эффектами понимается срабатывание хуков: useEffect, useLayoutEffect, useInsertionEffect и методов componentDidMount, componentWillUnmount. Далее я буду писать просто эффекты чтобы заново не перечислять хуки и методы. Напишем несколько примеров типичных решений для адаптации компонентов, новому поведению React 18 StrictMode и функции React Fast Refresh. Все примеры в этой статье буду запущены в codesandbox, React запущен в режиме разработки и с включенным StrictMode.

React 18 вышел в марте 2022 года, и для многих материал статьи не будет новым. Новое поведение Strict Mode уже частично было упомянуто в написанных ранее статьях: React 18: что нужно знать о новой версии и Основные изменения React 18, но я подумал неплохо было бы написать на тему Reusable State и Strict Mode небольшую статью с примерами кода и не большими размышлениями.

Мотивация к написанию статьи

Поделиться опытом и получить отзывы. Второе это сделать статью, которая не только объяснит почему происходят эти срабатывания, зачем это было сделано, но также будут даны рекомендации и решения, подходящие для много кратного срабатывания эффектов, на которое можно давать ссылку на форуме.

Надеюсь я все это сделал не зря и буду рад любому отзыву, приятного чтения!

Новое поведение Strict Mode и Reusable State

Reusable State

Это состояние, которое останется после размонтирования, и будет использовано при монтировании, но сохраняться состояние будет только с использованием функций с поддержкой Reusable State, а не при любом размонтировании. Также стоит упомянуть что Reusable State это часть функционала Concurrent React (предпоследний абзац в этом разделе).

Работает это так: состояние, созданное с помощью useState или useRef и библиотек управления состоянием будет сохраняться после размонтирования, а при монтировании сохранённое состояние будет подгружаться, и будут повторно срабатывать эффекты.

Мотивация для введения Reusable State: в будущем React сможет добавлять и удалять разделы пользовательского интерфейса с сохранением состояния, это должно повысить производительность

Источник документация Strict Mode

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

Новое поведение Strict Mode в режиме разработки в React 18 в сравнении с React 17.

  • Монтирование(React17/React18) - запуск эффектов монтирования.

  • Симуляция размонтирования(React18) - выполнение эффектов размонтирования.

  • Симуляция монтирования (React18)- запуск эффектов монтирования.

Все три этапа выполняются синхронно (пример в следующей разделе), а установка состояния выполняется асинхронно, и что важнее оно будет устанавливаться асинхронно 2 раза, т.е. доступ к новому состоянию мы получим только после симуляции монтирования, т.е. даже на повторном монтировании мы не увидим изменений. Немного забегая вперед, для того чтобы узнать что монтирование выполняется я буду использовать ref потому что изменения выполняются синхронно и значение сохраняется после симуляции размонтирования.

На reactwg github discussion было создано 2 обсуждения и как видно из комментариев сообщество React приняло эту новость с долей непонимания:

Пример демонстрация нового поведения Strict Mode

Тут ничего адаптировать не будем, а продемонстрируем синхронное выполнение этапов монтирований и размонтирований:

root.render(
  <StrictMode>
    <App />
  </StrictMode>
);

Демо код и текст примера:

const Component = () => {
  const simulation = React.useRef(false);
  console.log("render");
  React.useEffect(() => {
    if (simulation.current) {
      console.log("mount simulation");
    } else {
      console.log("mount");
      simulation.current = true;
    }
    Promise.resolve().then(() => (simulation.current = false));
    return () => {
      if (simulation.current) {
        console.log("unmount simulation");
      } else {
        console.log("unmount ");
      }
    };
  }, []);
  return <>Component Mounted</>;
};

В режиме разработки и включенном строгом режиме при монтировании мы увидим сообщения в консоли:

render
render
mount
unmount simulation
mount simulation

После сборки проекта будет всего одно сообщение, я собирал и запускал с помощью NextJS:

mount

При размонтировании в обоих режимах мы увидим только одно сообщение:

unmount

Promise в эту задачу был добавлен чтобы после выполнения симуляций, которые выполняется синхронно, установить simulation.current в false для того что бы при реальном размонтировании компонента мы получили сообщение "mount" вместо "unmount simulation". По другому узнать что это симуляция пока невозможно.

Новые функции использующие Reusable State

Разработчики React планируют добавлять новые функции, которые будут использовать Reusable State. Пока известно о двух функциях. Источник #19 Adding Reusable State to StrictMode

Первая функция это Fast Refresh, пришла на смену HMR еще в React 16.9 2020 году как экспериментальная. Сегодня Fast Refresh уже не экспериментальная и добавлена в React и React Native. Каждый раз, когда вы редактируете и файл в режиме разработки, выполнится повторный рендеринг с текущим состоянием, потому что есть поддержка reusable state, но эффекты будут запущены повторно, если компонент выйдет из строя из-за повторного запуска эффектов, он не будут хорошо работать с новыми разрабатываемыми функциями. Функция Fast Refresh более подробно описана в статье: React Fast Refresh

Под эффектами понимается код, который запускается в классовых и функциональных компонентах при повторном монтировании:

  • У функциональных компонентов это хуки у которые есть, аргумент зависимостей, т.е. такие как useEffect, useReducer, useCallback

  • У классовых компонентов это функции componentDidMount, componentWillUnmount

Несколько продуктов которые поддерживают Fast Refresh:

Вторая функция пока еще в разработке Offscreen API - позволит нам лучше поддерживать пользовательские интерфейсы, такие как контейнеры с вкладками и виртуализированные списки, а также лучше использовать новые браузерные API, такие как content-visibility. Это также поможет с оптимизацией предварительного рендеринга, над которой работает команда React Native. Но чтобы достичь этого, нам нужно внести изменения в то, как работают эффекты.

Несколько слов о подходах к решению проблемы повторного монтирования

Сразу обозначу проблему: После симуляции размонтирования, происходит симуляция монтирования, которая повторно запускает эффекты.

Почему это проблема может быть. Если мы запускаем функции с побочными эффектами (side effect) - это такие функции эффект которых действует на что-то помимо возвращаемого значения функции. Примерами могут быть: изменение локального состояния компонента, обращение к внешнему API, отправка действия в библиотеку управления состоянием, отправка сообщения в консоль и т.д. Проблемой это становится если по алгоритму оно должно запускаться 1 раз, вместо нескольких.

Сейчас нужно адаптировать компоненты к повторному монтированию только в режиме разработки к новому поведению Strict Mode и функции React Fast Refresh. Функционал React Fast Refresh и Reusable State существовал еще до 18 версии React, а новое поведение Strict Mode было добелено именно в 18 версии.

В продакшен режиме дополнительных монтирований пока не обнаружено, ждем новое API.

Последовательность монтировании / размонтирований:

  • StrictMode вызывает синхронно: mount > simulation unmount > mount

  • React Resfresh вызывает синхронно при каждом сохранении файла: unmount > mount

Демонстрация проблемы

В этом примере компонент не адаптирован, в нем вызываются эффекты на каждое монтирование и размонтирование, которые происходят с новым поведением Strict Mode и React Fast Refresh при редактировании исходного файла модуля. Конечно сам вызов эффектов не является проблемой если именно так и задумывалось, да и вообще может не мешать разработке если понимаешь в чем дело и не лень лишний раз обновить страницу.

В примере демонстрируются хуки вызывающие эффекты, которые вычисляют новое сосотояние на основе старого, а не заменяя его на новое.

  React.useEffect(() => {
    setState((prev) => prev + 1);
    return () => {
      console.log("useEffect unmount:");
    };
  }, []);

Предлагаемые решения

Использование ref - в офф доке, рекомендуется использовать ref для прямого доступа к HTML DOM элементам, но ref также хорошо подходят для хранения значения переменных, сохраняющихся между рендерами и после симуляции размонтирования, в отличии от состояния ref изменяется синхронно и его изменение не вызывает перерисовку компонента.

Использование stateless компонентов, использовать для этого библиотеки управления состоянием таких как redux, mobx и т.д. подразумевается вынесение управления состоянием компонента за пределы компонента (использование stateless компонентов), нам останется следить за тем чтобы не отправить лишних запросов на изменение состояния. Пример Redux

Предотвращение запуска эффектов при симуляции монтирования и размонтирования, продемонстрировано в решении "используем debounce для адаптации компонентов"

Также хорошей идеей будет использовать практики, которые помогут или полностью избавят от повторных эффектов или снизят их количество:

  • Применение Композиции - так как react refresh перезагружает, конкретно редактируемый модуль, то как вариант можно попробовать разбивать компоненты, на более мелкие. Будет продемонстрированно в главе.

  • Использовать библиотеки адаптированные к react 18 - это поможет справиться с новым поведением strict mode, например react query не будет делать 2 запроса на этапе монтирования с включенным Strict Mode.

  • Использовать кеширование - также на примере react query можно с помощью кеширования снизить число запросов к внешнему API, дополнительно можно на время редактирования файла включить опцию выполнения запросов по требованию вместо автоматических запросов, за это отвечает опция enabled.

Полный код примера react query

Код использования хука usePosts в компоненте:

const { status, data, error, isFetching /*refetch*/ } = usePosts(/*false*/);

Код хука usePosts для удобства передаем опцию включения ручного режима через параметр хука:

export function usePosts(enabled: boolean = true) {
  return useQuery({
    queryKey: ["posts"],
    enabled,
    queryFn: async (): Promise<Array<TPost>> => {
      const { data } = await axios.get(
        "https://jsonplaceholder.typicode.com/posts"
      );
      console.log("usePosts queryFn trigger");
      return data;
    }
  });
}

Альтернативным решением возможно даже хорошей идеей сейчас, пока нет нового API в продакшен, может быть отключение StrictMode и React Fast Refresh.

Отключение StrictMode

Как написано на странице документации react strict mode,

Проверки строгого режима работают только в режиме разработки; они не оказывают никакого эффекта в продакшен-сборке.

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

root.render(
  <React.StrictMode>
    <App />
   </React.StrictMode>
);

Отключение React Fast Refresh

Основная причина отключить это то что каждое изменения исходного файла перезапускает все хуки у которых есть зависимости, и после много кратных перезапусков нет уверенности что состояние компонента в порядке, или просто не хочется смотреть как браузер пытается отобразить страницу во время изменения кода. Чтобы быть уверенным что все в порядке проще перезагрузить страницу с компонентом в котором меняли код по f5.

Вторая причина React Refresh при работе с классовыми компонентами не сохраняет состояние, после размонтирования. Рассмотрим работу функции React Fast Refresh на этом примере с классовыми компонентами, как только мы редактируем файл - состояние сбрасывается. Я задал вопросы на форуме stackoverflow и в дискуссии #19 discussion - я это сделал недавно, так что ответов пока нет.

Для того чтобы отключить в проекте, в котором используется react-scripts можно отредактировать скрипт запуска например так ( смотри react-scripts настройки тут):

  "scripts": {
    "start": "cross-env FAST_REFRESH=false WDS_SOCKET_HOST=unknown react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },

Тут две опции: FAST_REFRESH=false - отключает Fast refresh, после отключения автоматически заработает HMR, для отключения этой опции я использовал WDS_SOCKET_HOST=unknown. Насколько мне известно в react-scripts нет доступа к webpack.config.json для 5 версии, для 4ой я находил проект, но это было давно… может что-то поменялось, если кото-то подскажет буду рад.

Для проекта без react-scripts нужно любым способом передать FAST_REFRESH=false как переменную среды, а в webpack.config.json (смотри настройки) запретить HMR например так:

    devServer: {
        hot: false,
    },

Пишем код для адаптации компонентов

Инициализируем что-то на эффекте монтирования и уничтожаем при размонтировании

Этот пример был взят из #18 How to support Reusable State in Effects. Этот пример хорошо работает не только с многократным вызовом эффектов, это можно сказать обычная практика, очень похож на паттерн Observer, инициализируем на эффекте монтирования и уничтожаем при размонтировании, и можем много кратно это повторить.

Код примера 1 - Допустим у нас есть модуль, который рассылает сообщения другим модулям. Подписываемся / отписываемся на рассылку каждое монтирование и размонтирование. тут мы по сути выносим управление состоянием из компонента и отдаем его в императив рассылки сообщений.

import React from "react";
import { SomeImperativeThing } from "./SomeImperativeThing";

const Component = () => {
  const [message, setMessage] = React.useState();
  React.useEffect(() => {
    // Создаем на каждое монтирование
    const someImperativeThing = new SomeImperativeThing(setMessage);
    return () => {
      // уничтожаем на каждое размонтирование
      someImperativeThing.destroy();
    };
  }, []);
  return (
    <div>
      <div>Component mounted</div>
      <div>Mesage of the day {message}</div>
    </div>
  );
};

export default Component;

Код примера 2 - тоже что и пример 1, дополнительно храним вещь в ref чтобы к ней был доступ за пределами хука useEffect, например для того чтобы получить список 10 последних отосланных сообщений.

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

Эффект, сработает только 1 раз на монтирование

Код примера - В этом примере ref используется для предотвращения выполнения эффекта более одного раза.

const Component = () => {
  const didLogRef = React.useRef(false);

  React.useEffect(() => {
    if (didLogRef.current === false) {
      // этот код будет выполнен только один раз,
      didLogRef.current = true;
      SomeTrackingAPI.logImpression();
    }
  }, []);

  return <>Component Loaded</>;
};

Используем debounce для адаптации компонентов

Код примера

Код не адаптированного компонента:

const Component = () => {
  const [state, setState] = React.useState<number | null>();

  React.useEffect(() => {
    setState(Date.now());
    console.log("mount enabled");
    return () => {
      console.log("unmount enabled");
      setState(null);
    };
  }, []);

  React.useEffect(() => {
    console.log("state changed enabled", state);
  }, [state]);

  return (
    <div>
      <div>The not adapted component is loaded</div>
      <div>
        <button onClick={() => setState(Date.now())}>Change State</button>
      </div>
      <div>State: {state}</div>
    </div>
  );
};

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

const Component = () => {
  const [state, setState] = React.useState<number | null>();
  const d = React.useRef<null | TDebounce>(null);
  React.useEffect(() => {
    if (d.current === null) {
      d.current = debounce();
    }
    const f = d.current;
    if (f.effect("mount")) {
      setState(Date.now());
      console.log("mount enabled");
    } else {
      console.log("sim mount disabled");
    }
    return () => {
      f.unmount(() => {
        console.log("unmount enabled Component Adapt");
        setState(null);
      });
    };
  }, []);

  React.useEffect(() => {
    if (d.current?.effect("change")) {
      console.log("state changed enabled", state);
    } else {
      console.log("sim state changed disabled");
    }
  }, [state]);

  return (
    <div>
      <div>The adapted component is loaded</div>
      <div>
        <button onClick={() => setState(Date.now())}>Change State</button>
      </div>
      <div>State: {state}</div>
    </div>
  );
};

Код debounce:

enum EDebounceState {
  BeforeReMount,
  AfterReMount
}

interface IDebounce {
  (): { effect: (name: string) => boolean; unmount: (cb: Function) => void };
}

export const debounce: IDebounce = () => {
  let state: EDebounceState = EDebounceState.BeforeReMount;
  const triggerReMount = new Set<string>();
  let timeoutUnMountCancel: ReturnType<typeof setTimeout> | null = null;
  const effect = (name: string) => {
    if (timeoutUnMountCancel) {
      console.log("sim unmount disabled");
      clearTimeout(timeoutUnMountCancel);
      timeoutUnMountCancel = null;
    }
    if (state === EDebounceState.AfterReMount) {
      if (triggerReMount.has(name)) return true;
      triggerReMount.add(name);
      return false;
    }
    return true;
  };
  const unmount = (cb: Function) => {
    timeoutUnMountCancel = setTimeout(() => cb());
    state = EDebounceState.AfterReMount;
    triggerReMount.clear();
  };
  return {
    effect,
    unmount
  };
};

debounce возвращает объект с двумя методами:

  • effect(name - имя эфеекта) - подсчитывает запуски эффектов после размонтирования и проверяет может ли объект сработать;

  • unmount(cb) - идентифицирует размонтирование, т.е. этот метод должен быть вызван только в одном эффекте, которые будет идентифицировать что компонент размонтировался. Так же этот метод принимает каллбэк для действий размонтирования.

работа debounce основана на том что после симуляции размонтирования, будет выполнена симуляция монтирования, с повторным запуском эффектов. Будут запущены эффекты, которые запустились более одного раза после размонтирования. Каждое размонтирование счетчик запуска эффектов обнуляется. Эффекты идентифицируются по имени, которое передается в методе effect.

Выводы

Для меня самым удобным способом адаптации компонентов стало отключение функции React Fast Refresh, я перегружаю страницу по f5 чтобы увидеть новые изменения, новое поведение Strict Mode не мешает, его ввели чтобы подготовить компоненты. Буду рад прочитать Ваше мнение.

Ссылка на статью на моем сайте с интерактивным оглавлением

Источники которые использовались для составления статьи:

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Как вы адаптируете компоненты к повторному вызову эффектов и reusable state?
39.13% никак9
17.39% отключаю strict mode и react fast refresh4
26.09% отключаю strict mode6
0% отключаю react fast refresh0
8.7% использую один из предложенных вариантов2
13.04% у меня свои решения3
Проголосовали 23 пользователя. Воздержались 5 пользователей.
Теги:
Хабы:
Всего голосов 1: ↑0 и ↓1-1
Комментарии0

Публикации

Истории

Работа

Ближайшие события

One day offer от ВСК
Дата16 – 17 мая
Время09:00 – 18:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн
Антиконференция X5 Future Night
Дата30 мая
Время11:00 – 23:00
Место
Онлайн
Конференция «IT IS CONF 2024»
Дата20 июня
Время09:00 – 19:00
Место
Екатеринбург
Summer Merge
Дата28 – 30 июня
Время11:00
Место
Ульяновская область