Главная / Статьи / Минимальный вариант использования хука useFetch: когда можно обойтись без React Query и его ограничения

Минимальный вариант использования хука useFetch: когда можно обойтись без React Query и его ограничения

Создайте небольшой хук useFetch с кэшированием, использующий AbortController и параметр TTL для истечения срока действия, а также вспомогательный хук useMutation; вы узнаете, какие именно функции React Query придется отказаться использовать.

2016 слов

React Query (сейчас TanStack Query) стал стандартным решением для загрузки данных в React, и это отличное программное обеспечение. Однако во многих проектах практически каждый вызов выполняет одно и то же: загружает данные, отображает индикатор загрузки, показывает ошибку и избегает повторной загрузки. В этом руководстве создается небольшой хук useFetch, не зависящий от внешних библиотек, для таких случаев; добавляются функции кэширования с временем жизни и возможность выполнения мутаций. Также честно описываются недостатки, унаследованные от других решений, чтобы вы могли принимать решения для каждого проекта индивидуально, а не следовать стандартным настройкам.

Как выглядит типичное использование

Часто большинство случаев применения React Query сводится к одному шаблону: ключ вместе с функцией, которая загружает и парсит JSON.

const { data, isLoading, error } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json())
});

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

В чём действительно сильен React Query

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

  • Фоновая перезагрузка данных, пока пользователь находится на странице.
  • Обновление связанных запросов после выполнения изменений.
  • Пагинация и бесконечное прокручивание.
  • Оптимистичные обновления до подтверждения сервером.
  • Устранение дубликатов: два компонента, запрашивающие одни и те же данные, отправляют только один запрос.

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

Почему ручно написанные эффекты уступают

Классический подход использует три переменные состояния и один эффект:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then(r => r.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  return <div>{user.name}</div>;
}

Этот метод работает, но имеет недостатки:

  • Устаревшие ответы. Если компонент удаляется или значение userId меняется во время запроса, старый ответ всё равно вызывает setState. В React 18 исчезло предупреждение об обновлении удаляемого компонента, но медленный ответ для предыдущего идентификатора всё ещё может перезаписать более свежие данные.
  • Отсутствие дедупликации. Два компонента — два запроса.
  • Отсутствие кэширования. При возвращении на экран данные снова загружаются.
  • Повторение кода в каждом компоненте.

Создание хука useFetch

Хук хранит модульный Map, ключами которого являются URL-адреса. Изначальное состояние считывается из него синхронно, поэтому данные из кэша отображаются без момента загрузки. При успешном поиске в кэше запрос завершается сразу; в противном случае вместе с запросом отправляется сигнал AbortController, статусы, отличные от 2xx, считаются ошибками, а результаты сохраняются в кэше. Функция очистки прерывает запрос при изменении URL или демонтировании компонента, ошибки AbortError игнорируются, а фальшивый URL пропускается при загрузке.

// useFetch.js
import { useState, useEffect, useRef } from 'react';

const cache = new Map();

export function useFetch(url, options = {}) {
  const [state, setState] = useState({
    data: cache.get(url) ?? null,
    loading: !cache.has(url),
    error: null
  });

  const abortRef = useRef(null);

  useEffect(() => {
    if (!url) return;
    if (cache.has(url)) {
      setState({ data: cache.get(url), loading: false, error: null });
      return;
    }

    abortRef.current = new AbortController();

    setState(prev => ({ ...prev, loading: true, error: null }));

    fetch(url, { signal: abortRef.current.signal, ...options })
      .then(r => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json();
      })
      .then(data => {
        cache.set(url, data);
        setState({ data, loading: false, error: null });
      })
      .catch(err => {
        if (err.name === 'AbortError') return;
        setState({ data: null, loading: false, error: err.message });
      });

    return () => abortRef.current?.abort();
  }, [url]);

  return state;
}

Хотя указано «25 строк», на самом деле их около сорока, но это всё равно немного. Код обеспечивает отображение состояния загрузки, обработку ошибок, кэширование, отмену устаревших запросов (см. проблемы конкурентности, которые нельзя решить с помощью дебаунсинга), очистку при демонтировании и мгновенное отображение из кэша.

Два замечания: в зависимостях эффекта отсутствует options, поэтому изменение только заголовков не приведёт к повторному запросу, а ключ кэша, основанный только на URL, позволяет запросам с разным телом или заголовками авторизации сталкиваться друг с другом.

Использование хука

Доступ к данным становится одной строкой. Формат похож на React Query, но флаг — loading, а не isLoading.

function UserProfile({ userId }) {
  const { data: user, loading, error } = useFetch(`/api/users/${userId}`);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  return <div>{user.name}</div>;
}

Два компонента, один URL

Здесь заголовок и боковая панель читают одного и того же пользователя.

function Header({ userId }) {
  const { data: user } = useFetch(`/api/users/${userId}`);
  return <div>Hello, {user?.name}</div>;
}

function Sidebar({ userId }) {
  const { data: user } = useFetch(`/api/users/${userId}`);
  return <div>{user?.email}</div>;
}

Именно здесь хук даёт чрезмерные обещания. Если оба компонента монтируются одновременно, ни один не находит запись в кэше, поэтому отправляются два запроса; пользу получают только те, что монтируются позже. Настоящее устранение дубликатов подразумевает кэширование обещания в процессе выполнения, а не только результирующих данных.

Условный и параметризованный запрос

Передача null отключает запрос, как это делается с помощью опции enabled в React Query.

function UserPosts({ userId, enabled }) {
  const { data, loading } = useFetch(enabled ? `/api/users/${userId}/posts` : null);
  // Pass null to skip fetching
}

Поскольку URL является зависимостью эффекта, URL, сгенерированный на основе параметров, перезагружается при изменении category или page. Кодируйте ввод пользователя с помощью encodeURIComponent.

function ProductList({ category, page }) {
  const url = `/api/products?category=${category}&page=${page}`;
  const { data, loading, error } = useFetch(url);
  // Refetches automatically when category or page changes
}

Панель управления с тремя источниками

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

function Dashboard({ userId }) {
  const { data: user, loading: userLoading } = useFetch(`/api/users/${userId}`);
  const { data: stats, loading: statsLoading } = useFetch(`/api/stats/${userId}`);
  const { data: activity, loading: actLoading } = useFetch(`/api/activity/${userId}`);

  return (
    <div>
      <Header>
        {userLoading ? <Skeleton /> : <UserInfo user={user} />}
      </Header>

      <StatsPanel>
        {statsLoading ? <Skeleton /> : <Stats data={stats} />}
      </StatsPanel>

      <ActivityFeed>
        {actLoading ? <Skeleton /> : <Activity items={activity} />}
      </ActivityFeed>
    </div>
  );
}

Элементы с истекающим сроком действия TTL

Кэш, который никогда не истекает, не подходит для данных, которые меняются. Хранение временной метки для каждого элемента и удаление элементов, старше пяти минут, решает эту проблему:

const cache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes

function setCache(url, data) {
  cache.set(url, { data, timestamp: Date.now() });
}

function getCache(url) {
  const entry = cache.get(url);
  if (!entry) return null;
  if (Date.now() - entry.timestamp > CACHE_TTL) {
    cache.delete(url);
    return null;
  }
  return entry.data;
}

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

const cached = getCache(url);

if (cached) {
  setState({ data: cached, loading: false, error: null });
  return;
}

// ... fetch logic ...
.then(data => {
  setCache(url, data);
  setState({ data, loading: false, error: null });
})

Для временных интервалов работы на уровне каждого вызова необходимо извлечь значение ttl из параметров и передать остальные данные в функцию fetch: короткие значения используются для цен, длинные — для профилей. В примере переменная data объявляется дважды в одном области видимости исключительно для демонстрации двух способов её использования.

export function useFetch(url, options = {}) {
  const { ttl = 5 * 60 * 1000, ...fetchOptions } = options;
  // Use ttl in cache logic
}

// Usage
const { data } = useFetch('/api/live-prices', { ttl: 30 * 1000 }); // 30 second cache
const { data } = useFetch('/api/user-profile', { ttl: 60 * 60 * 1000 }); // 1 hour cache

Записи с истекшим сроком действия удаляются только при их чтении, поэтому структура Map остается бесконечной в долгосрочных приложениях, обрабатывающих множество URL-адресов.

Вспомогательный хук useMutation

Записи выполняются по запросу вызывающего кода, а не при инициализации компонента. Этот хук отслеживает процесс загрузки, возникновение ошибок и наличие данных, отправляет JSON-тело запроса, считает ответы, не соответствующие коду 2xx, ошибками, а также сохраняет и перебрасывает эти ошибки.

// useMutation.js
import { useState } from 'react';

export function useMutation(url, method = 'POST') {
  const [state, setState] = useState({
    loading: false,
    error: null,
    data: null
  });

  const mutate = async (body) => {
    setState({ loading: true, error: null, data: null });

    try {
      const response = await fetch(url, {
        method,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body)
      });

      if (!response.ok) throw new Error(`HTTP ${response.status}`);

      const data = await response.json();
      setState({ loading: false, error: null, data });
      return data;
    } catch (err) {
      setState({ loading: false, error: err.message, data: null });
      throw err;
    }
  };

  return { ...state, mutate };
}

В форме кнопка отключается во время отправки запроса, а ошибки отображаются ниже. Обратите внимание, что метод onSubmit получает событие, а не данные формы, поэтому в реальном коде необходимо вызвать event.preventDefault() и собрать содержимое формы, например, с помощью new FormData(event.currentTarget).

function CreatePost() {
  const { mutate, loading, error } = useMutation('/api/posts');

  const handleSubmit = async (formData) => {
    try {
      const post = await mutate(formData);
      console.log('Created:', post);
    } catch (err) {
      // error state handles this
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* form fields */}
      <button disabled={loading}>
        {loading ? 'Creating...' : 'Create Post'}
      </button>
      {error && <p>{error}</p>}
    </form>
  );
}

Успешная операция изменения не затрагивает кэш useFetch, поэтому связанные списки остаются устаревшими до истечения срока их действия или до тех пор, пока вы вручную не удалите соответствующие ключи.

Что вы теряете

Повторное загрузка на фоне

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

useEffect(() => {
  const handleFocus = () => {
    cache.delete(url); // Invalidate cache
    // Trigger refetch
  };
  window.addEventListener('focus', handleFocus);
  return () => window.removeEventListener('focus', handleFocus);
}, [url]);

Оптимистичные обновления, инструменты разработчика, пагинация и аннулирование

  • Оптимистичные обновления с корректным откатом сложны в реализации, и здесь явно преимущество у библиотеки.
  • Инструменты разработчика отображают кэш, запросы и временные показатели; в домашней версии используется console.log.
  • useInfiniteQuery хорошо спроектирован; настройка собственной пагинации требует значительно больше усилий.
  • Глобальная аннулирование, такое как повторный запрос к каждому элементу типа «posts», превращается в ручное очистку кэша.

Выбор между двумя вариантами

Лучше использовать React Query для аннулирования данных при изменениях, фонового обновления информации, бесконечного прокручивания или сложной пагинации, а также для работы с инструментами разработчика, если ваша команда уже знакома с ним. Небольшой хук подходит тогда, когда экраны в основном занимаются загрузкой и отображением данных, достаточно постоянной или временной кэширования, важна отсутствие зависимостей, и приложение имеет небольшие или средние размеры. Если вы решите остаться с этой библиотекой, ознакомьтесь с структурированием слоя данных TanStack Query.

Поэтапная миграция

Перемещайте по одному компоненту за раз: меняется импорт и вызов хука, а также происходит переименование isLoading в loading.

// Before
import { useQuery } from '@tanstack/react-query';

const { data, isLoading, error } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json())
});

// After
import { useFetch } from './useFetch';

const { data, loading, error } = useFetch(`/api/users/${userId}`);

Когда ничто больше не импортирует эту библиотеку, удалите её.

npm uninstall @tanstack/react-query

Основные выводы

  • Большинство операций загрузки данных являются простыми, и Map с временными метками может служить подходящей кэш-системой для простых задач.
  • AbortController — это встроенное решение для устаревших или неправильно организованных ответов.
  • Минимальный хук хранит результаты, а не запросы в процессе выполнения, поэтому одновременная инициализация всё равно приводит к дублированию вызовов.
  • Каждый вариант библиотеки имеет свои ограничения; используйте инструменты в соответствии с их назначением, а не из привычки.
  • Определите, нужно ли проекту что-то большее, чем простое получение данных, и выбирайте инструменты соответственно.