Minimalny hook useFetch: Kiedy można pominąć React Query i jakie są jego ograniczenia
Stwórz mały hook useFetch z buforowaniem, wykorzystujący AbortController oraz okres ważności TTL, wraz z towarzyszącym mu hookiem useMutation, i dowiedz się dokładnie, jakie funkcje React Query tracisz.
React Query (obecnie TanStack Query) stał się standardowym rozwiązaniem do ładowania danych w React i jest doskonałym oprogramowaniem. Jednak w wielu bazach kodowych niemal każde wezwanie wykonuje to samo: pobiera dane, pokazuje wirtualny wirnik, wyświetla błąd i unika ponownego ładowania. Ten przewodnik tworzy mały, niezależny od zależności hook useFetch do takich przypadków, dodaje mechanizm cache’owania z czasem ważności oraz możliwość dokonywania mutacji, a także szczerze opisuje istniejące luki, abyś mógł podjąć decyzję dla każdego projektu zamiast korzystać z domyślnych ustawień.
Jak wygląda typowe użycie
Często większość zastosowań React Query opiera się na jednym wzorcu: kluczu w połączeniu z funkcją, która pobiera i parsuje dane w formacie JSON.
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json())
});
Wystarczy tu tylko ładowanie, obsługa błędów i cacheowanie. Biblioteka oferuje również narzędzia do tła do ponownego pobierania danych, unieważniania zapytań, paginacji i nieskończonego przewijania, aktualizacje optymistyczne, narzędzia deweloperskie, synchronizację stanu serwera oraz operacje mutacji z możliwością cofnięcia. Jeśli nic z tego nie jest używane, płaci się za rozmiar pliku i złożoność tylko za trzy zmienne. Sprawdź sam aktualny rozmiar pliku – wartości mogą się różnić w zależności od wersji.
Czym naprawdę doskonale radzi sobie React Query
Jego głównym zadaniem jest zarządzanie stanem serwera, czyli utrzymywanie synchronizacji interfejsu z danymi znajdującymi się na serwerze. Wyróżnia się w następujących obszarach:
- Ponowne pobieranie danych w tle, gdy użytkownik jest na stronie.
- Odświeżanie powiązanych zapytań po dokonaniu mutacji.
- Paginacja i nieskończone przewijanie.
- Aktualizacje optymistyczne przed potwierdzeniem przez serwer.
- Usuwanie duplikatów: dwa komponenty żądające tych samych danych wysyłają tylko jedno zapytanie.
Jeśli są one potrzebne, to rozwiązanie ma sens. Wiele ekranów musi jedynie pobrać dane przy uruchomieniu, pokazać informację o ładowaniu, obsłużyć błędy i nie pobrawać ich ponownie bez potrzeby.
Dlaczego ręcznie pisane efekty są niewystarczające
Klasyczne podejście wykorzystuje trzy zmienne stanu oraz jeden efekt:
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>;
}
Funkcjonuje to, ale ma wady:
- Zastarzałe odpowiedzi. Jeśli komponent zostanie usunięty lub
userIdzmieni się w trakcie żądania, stara odpowiedź nadal wywołujesetState. React 18 usunął ostrzeżenie dotyczące aktualizacji po usunięciu komponentu, ale wolna odpowiedź dla poprzedniego identyfikatora może nadal przepisać nowsze dane. - Dwa komponenty – dwa żądania.
- Powrót na ekran powoduje ponowne pobieranie danych.
- Powtarzalność we wszystkich komponentach.
Budowa hooka useFetch
Hook przechowuje na poziomie modułu strukturę Map opartą na kluczach w postaci adresów URL. Początkowy stan jest z niej odczytywany synchronicznie, dzięki czemu dane z pamięci cache są wyświetlane bez efektu ładowania. W przypadku trafienia do pamięci cache operacja kończy się szybko; w przeciwnym razie do żądania dołączany jest sygnał AbortController, statusy inne niż 2xx są traktowane jako błędy, a wyniki są zapisywane w cache. Funkcja czyszczenia anuluje żądanie, gdy zmienia się adres URL lub komponent jest usuwany, błąd AbortError jest ignorowany, a fałszywy adres URL powoduje pominięcie pobierania danych.
// 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;
}
Chociaż podawane jest, że składa się z „25 wierszy”, w rzeczywistości ma ich bliżej czterdziestu, ale nadal jest niewielki. Zapewnia stan ładowania, obsługę błędów, cacheowanie, anulowanie przestarzałych żądań (zobacz warunki konkurencyjne, których nie da się rozwiązać za pomocą debouncingu), czyszczenie po usunięciu komponentu oraz natychmiastowe wyświetlanie danych z cache.
Dwie uwagi: w zależnościach efektu brakuje options, więc sama zmiana nagłówków nie spowoduje ponownego pobrania danych, a klucz cache oparty wyłącznie na URL sprawia, że żądania z różnymi ciałami lub nagłówkami autoryzacji mogą się kolidować.
Używanie hooka
Dostęp do danych sprowadza się do jednej linii kodu. Struktura przypomina tę z React Query, ale flagą jest loading, a nie 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>;
}
Dwie komponenty, jeden URL
Tutaj nagłówek i pasek boczny odczytują tego samego użytkownika.
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>;
}
Tutaj hook daje zbyt duże obietnice. Jeśli obie komponenty zostaną załadowane jednocześnie, żadna z nich nie znajdzie wpisu w cache, więc wyślone zostaną dwa żądania; korzyść przyniesie tylko późniejsze załadowanie. Prawdziwa deduplikacja oznacza przechowywanie w cache obietnicy w trakcie realizacji, a nie tylko ukończonych danych.
Warunkowe i parametryzowane pobieranie danych
Przekazanie wartości null wyłącza żądanie, podobnie jak opcja enabled w React Query.
function UserPosts({ userId, enabled }) {
const { data, loading } = useFetch(enabled ? `/api/users/${userId}/posts` : null);
// Pass null to skip fetching
}
Ponieważ URL jest zależnością efektu, URL utworzony na podstawie propów jest ponownie pobierany, gdy zmienia się category lub page. Koduj dane wprowadzone przez użytkownika za pomocą 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
}
Panel sterowania z trzema źródłami
Trzy wywołania działają równolegle, każda sekcja ma swój własny szkielet, nie ma dostawcy ani konfiguracji, a renderowanie jest ponownie wykonywane z pamięci cache.
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>
);
}
Wpisy wygasające według TTL
Pamięć cache, która nigdy się nie wygasza, jest niewłaściwa przy zmieniających się danych. Przechowywanie daty czasu dla każdego wpisu oraz usuwanie wpisów starszych niż pięć minut rozwiązuje ten problem:
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;
}
Hook następnie czyta i zapisuje dane za pośrednictwem tych pomocników; pokazywane są tylko zmienione części:
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 });
})
Dla czasu trwania na poziomie pojedynczej wywołania należy wydobyć ttl z opcji i przekazać resztę do funkcji fetch: krótki okres dla cen, dłuższy dla profili. Przykład deklaruje data dwa razy w jednym zakresie tylko po to, by pokazać dwa sposoby jego użycia.
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
Wpisy wygasłe są usuwane dopiero podczas odczytu, więc Map nie ma ograniczeń w aplikacji działającej przez długi czas i odwołującej się do wielu adresów URL.
Hook useMutation
Zapisy są inicjowane przez wywołującego, a nie przy uruchomieniu. Ten hook śledzi stan ładowania, błędy i dane, wysyła ciało w formacie JSON, traktuje odpowiedzi inne niż 2xx jako błędy oraz przechowuje i ponownie rzuca błędy.
// 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 };
}
W formularzu przycisk jest wyłączony podczas wysyłania żądania, a błędy są wyświetlane poniżej. Należy pamiętać, że onSubmit otrzymuje zdarzenie, a nie dane formularza, dlatego prawdziwy kod musi wywołać event.preventDefault() i utworzyć treść, na przykład za pomocą 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>
);
}
Sukcesywne modyfikacje nie wpływają na pamięć cache funkcji useFetch, więc powiązane listy pozostają przestarzałe, dopóki nie wygaśnie ich czas ważności lub jeśli ręcznie nie usuniesz kluczy.
Czego tracisz
Ponowne pobieranie w tle
React Query ponawia pobieranie danych przy skupieniu uwagi na oknie. Można to odtworzyć poprzez usuwanie wpisu z cache przy skupieniu uwagi, ale samo to nie uruchomi ponownie efektu; potrzebny jest również czynnik wyzwalający, taki jak licznik stanu.
useEffect(() => {
const handleFocus = () => {
cache.delete(url); // Invalidate cache
// Trigger refetch
};
window.addEventListener('focus', handleFocus);
return () => window.removeEventListener('focus', handleFocus);
}, [url]);
Aktualizacje optymistyczne, narzędzia deweloperskie, paginacja i unieważnianie
- Optymistyczne aktualizacje z poprawnym cofaniem są trudne do zrealizowania, a tutaj biblioteka wyraźnie przewyższa konkurencję.
- Narzędzia deweloperskie pokazują cache, zapytania oraz czasy ich wykonywania; wersja domowa korzysta z
console.log. useInfiniteQueryjest dobrze zaprojektowany; stworzenie własnej funkcji paginacji wymaga znacznie więcej pracy.- Globalna nieważność, np. ponowne pobieranie wszystkich zapytań typu „posts”, sprowadza się do ręcznego czyszczenia cache.
Wybór pomiędzy nimi
Zaleca się użycie React Query do anulowania danych po zmianach, ponownego pobierania w tle, przewijania nieskończonego lub złożonej paginacji, narzędzi deweloperskich, albo gdy zespół już zna tę bibliotekę. Mały hook nadaje się wtedy, gdy ekrany głównie pobierają i wyświetlają dane, wystarcza trwałe lub tymczasowe przechowywanie w pamięci, nie ma znaczenia brak zależności, a aplikacja jest mała lub średnia. Jeśli chcesz pozostać przy tej bibliotece, zapoznaj się z strukturyzacją warstwy danych TanStack Query.
Migracja stopniowa
Zmieniaj po jednym komponencie: zmienia się import i wywołanie hooka, a także nazwa z isLoading na 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}`);
Gdy nikt już nie importuje tej biblioteki, usuń ją.
npm uninstall @tanstack/react-query
Główne wnioski
- Większość operacji pobierania danych jest prosta, a
Mapz datami czasowymi stanowi odpowiednią formę cache’u dla prostych potrzeb.
AbortController to natywnie dostępne rozwiązanie problemu przestarzałych lub nieuporządkowanych odpowiedzi.