Мінімальний варіант використання Hook useFetch: коли можна обійтися без React Query та його обмеження
Створіть невеликий хук useFetch з кешуванням, який використовує AbortController та механізм TTL для закінчення терміну дії, а також супутній хук useMutation, і дізнайтеся, які саме функції React Query ви втрачаєте.
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 рядків“, насправді його кількість ближча до сорока, проте це все одно небагато. Він забезпечує відображення статусу завантаження, обробку помилок, кешування, скасування застарілих запитів (див. проблеми типу race conditions, які не можна вирішити за допомогою 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 для скасування даних після змін, фонового оновлення даних, безкінечного прокручування чи складної сторінкування, роботи з інструментами розробника, або коли ваша команда вже знайома з ним. Невеликий хук підходить тоді, коли екрани переважно завантажують та відображають дані, достатньо постійного чи тимчасового кешування, важлива відсутність залежностей, а додаток є невеликим чи середнього розміру. Якщо ви залишитеся з цією бібліотекою, дивіться структурування шару даних 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 — це вбудоване рішення для застарілих чи неправильно послідовних відповідей.