Галоўная / Артыкулы / Мінімальны хук 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

Яго галоўная задача — кантроль стану сервера, якая падтрымлівае сынхроннасць UI з даннымі на сервере. Ён выдатна справляецца з:

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

Якщо вам патрэбны гэтыя функцыі, ўсё мае свой сенс. Большасць экранаў патрэбна толькі запрашваць даныя пад час стварэння, паказваць індыкатор загрузкі, карацца з бягамі і не запрашваць іх занова без патрэбы.

Чаму ручнае стварэнне ефектаў не ўсёга дастатня

Класычны падход выкарыстоўвае тры зменны стану і адны ефект:

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 была адмовілася паведамленне пра зніманне компонента, але повольная адпаведзь для паканальнага id все ўсё можа перазапісаць новыя даны.
  • Няма выкарыстоўвання дублікатаў. Два компоненты — два запиты.
  • Няма кэшавання. Працэс вярнення на экран знову запрашвае даныя.
  • Павтарэнне у кожным компоненте.

Стварэнне хука 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 ліній", на самай працы ў яго болей сорак ліній, але гэта ўсё равно мало. Ён забезпечвае стан завантажэння, обробку памылак, кэшаванне, анулювання застарелых запитоў (дыв. условія конкурэнцыі, якія не можна рашыць за дапамогою тэхнік debouncing), чыстку пасля дэзактівацыі компонента і мгновеннае відображэння з кэша.

Два застережэння: у залежнасцях эфекта не ёсць элемента 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, створаны з props, перзапрашваецца, калі змянююцца 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 для анулювання дадзейна пасля змян, фонаваго перзапрашывання, безканальнага прасування або складнай структуры пагінацыі, інструментаў развіцця, або калі ваша команда вже знае яго. Маленькі хук падходзіць, калі экраны пераважна толькі запрашваюць і адображаюць дадзеныя, достатнія стацыонарныя або TTL-кэшы, важліва ўсунення залежнасцей, а прыложэнне є маленькім чырвонам. Якщо вы захочаце застацца з гэтай бібліятэкай, адзірніце структуруванне шара дадзейна 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 — гэта вялікі спосаб пры рашэнні проблемы застарелых, нераспараджаных адказоў.
  • Мінімальны хук кэшуе рэзультаты, а не запускаемыя запиты, таму паралельныя запускі все рава выклікаюць дубляванне вызоваў.
  • Кожны варыянт бібліятэкі мае свою функцыю; выбірайце інструмент па яго можлівасцям, а не з прыzwычкі.
  • З’ясавайце, чы рэквізуе проект што-небудзь аднародзе базавага запрашэння, і выбірайце вярныя рашэнні.