Ein minimaler useFetch-Hook: Wann Sie auf React Query verzichten können – und seine Grenzen
Erstellen Sie einen kleinen, in Cache gespeicherten useFetch-Hook mit AbortController, TTL-Ablauffrist sowie einem begleitenden useMutation-Hook und erfahren Sie genau, welche Funktionen von React Query Sie aufgeben müssen.
React Query (heute TanStack Query) ist zur Standardlösung für das Laden von Daten in React geworden und stellt hervorragende Software dar. Dennoch führt in vielen Codebasen nahezu jeder Aufruf dasselbe aus: Daten abrufen, einen Ladeindikator anzeigen, einen Fehler anzeigen und ein erneutes Abrufen vermeiden. Diese Anleitung erstellt für diesen Fall einen kleinen, abhängigkeitsfreien useFetch-Hook, fügt TTL-Caching sowie Mutationen hinzu und ist ehrlich bezüglich der vorhandenen Einschränkungen, damit Sie pro Projekt entscheiden können anstelle von Standardvorgaben.
Wie die typische Verwendung aussieht
Oft folgt die Nutzung von React Query einem einzigen Muster: ein Schlüssel zusammen mit einer Funktion, die Daten abruft und als JSON parsen.
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json())
});
Dafür sind lediglich Laden, Fehlerbehandlung und Caching erforderlich. Die Bibliothek bietet außerdem Hilfsmittel für das Hintergrund-Aufrufen neuer Daten, Invalidation, Paginierung und Endlosscrollen, optimistische Aktualisierungen, DevTools, Synchronisation des Server-Zustands sowie Mutationen mit Rollback-Funktion. Wenn nichts davon verwendet wird, zahlt man trotzdem für die Größe des Pakets und die Komplexität wegen nur drei Variablen. Überprüfen Sie selbst die aktuelle Paketgröße – die Werte variieren je nach Version.
Wofür React Query wirklich gut ist
Ihre Hauptaufgabe besteht in der Verwaltung des Server-Zustands, wodurch die Benutzeroberfläche mit den Daten auf dem Server synchron bleibt. Sie zeichnet sich besonders durch folgende Funktionen aus:
- Hintergrund-Aufrufen neuer Daten, während der Benutzer sich auf der Seite befindet.
- Aufrufen neuer Daten für verwandte Abfragen nach einer Mutation.
- Paginierung und Endlosscrollen.
- Optimistische Aktualisierungen, bevor der Server die Änderung bestätigt.
- Deduplizierung: Zwei Komponenten, die nach denselben Daten fragen, lösen nur einen Anfragenaufruf aus.
Falls Sie diese Funktionen benötigen, lohnt sich die Umsetzung. Viele Bildschirme müssen lediglich beim Laden Daten abrufen, ein Ladezeichen anzeigen, Fehlern begegnen und nicht unnötig erneut Daten holen.
Warum handgeschriebene Effekte nicht ausreichen
Der klassische Ansatz verwendet drei Zustandsvariablen sowie einen Effekt:
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>;
}
Er funktioniert, weist aber Schwächen auf:
- Veraltete Antworten. Wenn das Komponente deinstalliert wird oder sich
userIdwährend der Anfrage ändert, ruft die alte Antwort weiterhinsetStateauf. React 18 hat zwar die Warnung wegen deinstallierter Komponenten entfernt, doch eine langsame Antwort für einen früherenuserIdkann weiterhin neuere Daten überschreiben. - Keine Deduplizierung. Zwei Komponenten erfordern zwei Anfragen.
- Kein Caching. Beim Zurückkehren auf den Bildschirm werden die Daten erneut abgerufen.
- Wiederholung in jeder Komponente.
Erstellung des useFetch-Hooks
Der Hook speichert ein modulweites Map, das nach URL geordnet ist. Der Anfangszustand wird synchron daraus ausgelesen, sodass die in Cache gespeicherten Daten ohne Ladeanimation angezeigt werden. Bei einem Cache-Hit wird früh zurückgegeben; ansonsten wird ein AbortController-Signal mit der Anfrage gesendet, Statuscodes außerhalb von 2xx werden als Fehler behandelt und die Ergebnisse werden im Cache gespeichert. Die Aufräumfunktion beendet die Anfrage, wenn sich die URL ändert oder das Komponente entmontiert wird, AbortError-Fehler werden ignoriert und falsche URLs vermeiden das Herunterladen.
// 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;
}
Auch wenn es als „25 Zeilen“ angegeben wird, sind es eher vierzig – dennoch noch überschaubar. Es bietet einen Ladezustand, Fehlerbehandlung, Caching, die Stornierung veralteter Anfragen (siehe Race Conditions, die durch Debouncing nicht gelöst werden können), eine Aufräumfunktion beim Entmontieren sowie sofortige Anzeige aus dem Cache.
Zwei Einschränkungen: options fehlt in den Abhängigkeiten des Effekts, sodass ein alleiniger Wechsel der Header keine Neuabfrage auslöst, und die nur auf die URL basierende Cache-Schlüsselung kann dazu führen, dass Anfragen mit unterschiedlichen Inhalten oder Auth-Header kollidieren.
Nutzung des Hooks
Der Datenzugriff reduziert sich auf eine einzige Zeile. Die Struktur ähnelt der von React Query, doch die Flagge lautet loading, nicht 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>;
}
Zwei Komponenten, eine URL
Hier lesen ein Header und eine Seitenleiste denselben Benutzer.
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>;
}
Hier übertrifft der Hook seine Möglichkeiten. Wenn beide gemeinsam geladen werden, findet keines eine Cache-Einträge, wodurch Zwei Anfragen gesendet werden; nur spätere Ladevorgänge profitieren. Wahre Deduplizierung bedeutet das Caching des laufenden Promises, nicht nur der bereits verarbeiteten Daten.
Bedingte und parametrisierte Abfragen
Durch Übergeben von null wird die Anfrage deaktiviert, ähnlich wie bei React Queries enabled-Option.
function UserPosts({ userId, enabled }) {
const { data, loading } = useFetch(enabled ? `/api/users/${userId}/posts` : null);
// Pass null to skip fetching
}
Da die URL die Abhängigkeit des Effekts darstellt, wird eine aus Props gebildete URL neu geladen, wenn sich category oder page ändern. Kodieren Sie Benutzereingaben mit 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
}
Ein Dashboard mit drei Quellen
Drei Aufrufe laufen parallel ab, jede Section hat ihr eigenes Skelett, es gibt keinen Provider oder Konfiguration, und die Darstellung wird aus dem Cache wiederhergestellt.
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>
);
}
Auslaufende Einträge mit TTL
Ein niemals auslaufender Cache eignet sich nicht für ändernde Daten. Das Speichern eines Zeitstempels pro Eintrag sowie das Entfernen von Einträgen, die älter als fünf Minuten sind, behebt dieses 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;
}
Der Hook liest und schreibt anschließend über diese Hilfsfunktionen; nur die geänderten Teile werden angezeigt:
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 });
})
Für die Lebensdauer pro Aufruf sollte ttl aus den Optionen extrahiert und der Rest an fetch übergeben werden: kurz für Preise, lang für Profile. Das Beispiel deklariert data zweimal innerhalb desselben Scope, um zwei Verwendungsweisen zu zeigen.
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
Ausgelaufene Einträge werden nur beim Lesen gelöscht, wodurch der Map in einer langlaufenden Anwendung, die viele URLs aufruft, unbegrenzt bleibt.
Ein begleitender useMutation-Hook
Schreibvorgänge werden vom Aufrufer ausgelöst und nicht beim Initialisieren. Dieser Hook überwacht Ladevorgänge, Fehler sowie Daten, sendet einen JSON-Body, betrachtet Nicht-2xx-Antworten als Fehler und speichert sowie wirft Fehler erneut aus.
// 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 };
}
In dem Formular ist die Schaltfläche während der Anfrage deaktiviert und Fehler werden darunter angezeigt. Beachten Sie, dass onSubmit ein Event erhält und nicht die Formulardaten, weshalb der eigentliche Code event.preventDefault() aufrufen und den Inhalt beispielsweise mit new FormData(event.currentTarget) erstellen muss.
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>
);
}
Eine erfolgreiche Änderung berührt den useFetch-Cache nicht, sodass die zugehörigen Listen bis zum Ablauf ihrer TTL oder bis Sie die Schlüssel manuell löschen, veraltet bleiben.
Was Sie aufgeben
Hintergrund-Neuladung
React Query lädt bei Fokussierung des Fensters neu. Dies können Sie annähernd erreichen, indem Sie die Cache-Einträge beim Fokussieren löschen, doch allein diese Maßnahme führt nicht zur erneuten Ausführung des Effekts; es ist auch ein Trigger wie ein Zustandszähler erforderlich.
useEffect(() => {
const handleFocus = () => {
cache.delete(url); // Invalidate cache
// Trigger refetch
};
window.addEventListener('focus', handleFocus);
return () => window.removeEventListener('focus', handleFocus);
}, [url]);
Optimistische Aktualisierungen, Entwicklertools, Paginierung und Invalidation
- Optimistische Aktualisierungen mit korrektem Rollback sind schwierig, und hier hat die Bibliothek eindeutig Vorteile.
- Devtools zeigen Cache, Abfragen und Zeiten an; die selbst erstellte Version verwendet
console.log. useInfiniteQueryist gut gestaltet; eine benutzerdefinierte Paginierung erfordert deutlich mehr Arbeit.- Globale Invalidation, wie das Neuabrufen jeder „posts“-Abfrage, führt zu einer manuellen Löschung des Caches.
Auswahl zwischen den beiden
Wählen Sie React Query für mutationenbasierte Invalidation, Hintergrundabrufvorgänge, Endlosscrolling oder komplexe Paginierung, DevTools – oder wenn Ihr Team bereits damit vertraut ist. Ein kleiner Hook eignet sich, wenn die Benutzeroberflächen hauptsächlich Daten abrufen und anzeigen, permanente oder zeitbegrenzte Caching-Lösungen ausreichen, keine Abhängigkeiten vorhanden sind und die Anwendung klein bis mittelgroß ist. Falls Sie bei der Bibliothek bleiben, lesen Sie Wie man eine TanStack Query-Datenlage strukturiert.
Schrittweise Migration
Bewegen Sie sich Schritt für Schritt voran: Ändern Sie die Importe sowie den Aufruf des Hooks und ändern Sie außerdem isLoading in 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}`);
Sobald keine Komponente mehr die Bibliothek importiert, deinstallieren Sie sie.
npm uninstall @tanstack/react-query
Wichtige Erkenntnisse
- Der Großteil der Datenabrufe ist einfach, und ein
Mapmit Zeitstempeln eignet sich gut als Cache für einfache Anforderungen.
AbortController ist die eingebaute Lösung für veraltete oder fehlerhaft angeordnete Antworten.