Главная / Статьи

Выбор подходящего инструмента React: Derive, Handle, Fetch, Defer или Effect

Руководство по принятию решений о замене вызовов функции useEffect с рефлексивным поведением на производные значения, обработчики событий, слой данных, функцию useTransition, функцию useMemo с параметром measured и API React 19.

Издатель: REACTAPP.TOP8 мин чтения1896 слов

Источники: Stop Using `useEffect` for Everything: 7 React Patterns That Make Your Code Simpler

Многие компоненты React собирают вызовы useEffect для загрузки данных, реагирования на параметры, вычисления значений и синхронизации состояния. Через несколько месяцев вы открываете файл и обнаруживаете четыре операции типа Effect, три переменные производного состояния, два флага загрузки и баг, который никто не может объяснить. В этом руководстве предлагается один вопрос, который следует задать перед написанием любого хука: «Действительно ли здесь нужен Effect?», а также семь конкретных шаблонов того, что использовать, когда ответ — нет.

Производные значения должны находиться в функции render, а не в состоянии

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

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

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

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]);

Мемоизация — это деталь. Важен принцип: значение, получаемое из 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 по её завершении, поэтому нет временных элементов, которые нужно было бы удалять. Его предназначено использовать внутри операций или переходов; крайние случаи с ошибками запросов рассматриваются в нашей статье о режимах сброса при использовании оптимистичного подхода.

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

const id = useId();

вместо генерации случайного ID:

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, а уже затем его синтаксис.
  • Иногда наиболее эффективной оптимизацией в компоненте является удаление хука, а не его добавление.