Accueil / Articles / Un hook useFetch minimaliste : quand vous pouvez vous passer de React Query et ses limites

Un hook useFetch minimaliste : quand vous pouvez vous passer de React Query et ses limites

Créez un petit hook useFetch en mémoire cache utilisant AbortController, une expiration TTL, ainsi qu’un hook complémentaire useMutation, et découvrez précisément quelles fonctionnalités de React Query vous abandonnez.

2016 mots

React Query (maintenant TanStack Query) est devenu la solution par défaut pour le chargement des données dans React, et c’est un outil excellent. Cependant, dans de nombreux projets, presque toutes les appels effectuent la même chose : récupérer des données, afficher un indicateur de chargement, afficher une erreur, et éviter de recharger. Ce guide crée un petit hook useFetch sans dépendances pour ce cas, ajoute un mécanisme de mise en cache avec TTL ainsi que des fonctions de mutation, et est honnête quant aux limites inhérentes à cette approche, afin que vous puissiez prendre des décisions adaptées à chaque projet plutôt que de suivre des règles par défaut.

À quoi ressemble une utilisation typique

Souvent, l’utilisation de React Query suit un même schéma : une clé associée à une fonction qui récupère et analyse du JSON.

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

Cela nécessite uniquement le chargement, la gestion des erreurs et le cache. La bibliothèque propose également des outils pour le rechargement en arrière-plan, l’invalidation, la pagination et le défilement infini, des mises à jour optimistes, des outils de développement, la synchronisation de l’état du serveur ainsi que des mutations avec possibilité de réversion. Si aucun de ces éléments n’est utilisé, vous payez pour la taille du fichier et sa complexité alors que seules trois variables sont nécessaires. Vérifiez vous-même la taille actuelle du fichier ; les chiffres varient selon les versions.

En quoi React Query est vraiment performant

Sa fonction principale est la Gestion de l’état du serveur, qui permet de maintenir l’interface utilisateur en synchronisation avec les données sur le serveur. Il excelle dans :

  • Le rechargement en arrière-plan tant que l’utilisateur est sur la page.
  • Le renouvellement des requêtes associées après une mutation.
  • La pagination et le défilement infini.
  • Les mises à jour optimistes avant confirmation par le serveur.
  • La suppression des doublons : deux composants demandant les mêmes données déclenchent une seule requête.

Si vous en avez besoin, cela justifie son utilisation. De nombreux écrans n’ont besoin que de récupérer des données au chargement, d’afficher un indicateur de chargement, de gérer les échecs et de ne pas effectuer de nouvelles requêtes inutilement.

Pourquoi les effets écrits manuellement sont insuffisants

L’approche classique utilise trois variables d’état et un effet :

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>;
}

Cela fonctionne, mais présente des faiblesses :

  • Réponses obsolètes. Si le composant est démonté ou que userId change au milieu de la requête, l’ancienne réponse continue d’appeler setState. React 18 a supprimé l’avertissement concernant les mises à jour sur des composants démontés, mais une réponse lente pour un identifiant précédent peut toujours écraser des données plus récentes.
  • Aucune déduplication. Deux composants, deux requêtes.
  • Aucun stockage en cache. Le retour sur l’écran entraîne une nouvelle requête.
  • Répétition dans chaque composant.

Création du hook useFetch

Le crochet conserve un Map au niveau du module, indexé par URL. L’état initial est lu de manière synchrone, ce qui permet à les données en cache d’être affichées sans effet de chargement visible. En cas de correspondance dans le cache, la requête est traitée rapidement ; sinon, un signal AbortController est envoyé avec la requête, les statuts non 2xx sont considérés comme des erreurs et les résultats sont stockés en cache. La fonction de nettoyage annule la requête lorsque l’URL change ou que le composant est démonté, les AbortError sont ignorés, et une URL fausse empêche la récupération des données.

// 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;
}

Bien que présenté comme étant composé de « 25 lignes », il en compte plutôt quarante, ce qui reste modeste. Il offre un état de chargement, une gestion des erreurs, un système de cache, l’annulation des requêtes obsolètes (voir les conditions concurrentes que le décalage temporel ne peut pas résoudre), un nettoyage en cas de démontage et des affichages instantanés à partir du cache.

Deux mises en garde : options fait défaut dans les dépendances de l’effet, donc modifier uniquement les en-têtes ne provoquera pas un rechargement, et la clé de cache basée uniquement sur l’URL peut entraîner des conflits entre des requêtes ayant des corps ou des en-têtes d’authentification différents.

Utilisation du hook

L’accès aux données se résume à une seule ligne. Sa structure ressemble à celle de React Query, mais le flag est loading, et non 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>;
}

Deux composants, une URL

Ici, l’en-tête et la barre latérale lisent les mêmes informations utilisateur.

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>;
}

C’est là que le hook fait des promesses excessives. Si les deux composants sont montés en même temps, aucun ne trouve d’entrée dans le cache, ce qui entraîne deux requêtes envoyées ; seuls les composants montés plus tard en bénéficient. Une véritable déduplication consiste à stocker la promesse en cours d’exécution, et non seulement les données finalisées.

Récupération conditionnelle et paramétrée

Passer null désactive la requête, tout comme l’option enabled de React Query.

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

Puisque l’URL est la dépendance d’effet, une URL générée à partir des props se recharge lorsque category ou page change. Encodez les entrées fournies par l’utilisateur avec 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
}

Un tableau de bord avec trois sources

Trois appels s’exécutent en parallèle, chaque section ayant son propre squelette, sans fournisseur ni configuration, et reprend la rendu depuis le 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>
  );
}

Entrées expirant selon une durée de vie

Un cache qui ne expire jamais est inadapté aux données changeantes. En stockant une date et heure pour chaque entrée et en supprimant celles qui ont plus de cinq minutes, on résout ce problème :

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;
}

Le hook lit ensuite et écrit à travers ces outils d’aide ; seules les parties modifiées sont affichées :

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 });
})

Pour les durées de vie par appel, extraire ttl des options et transmettre le reste à fetch : une valeur courte pour les prix, une valeur longue pour les profils. L’exemple déclare data deux fois dans un même contexte uniquement pour montrer deux utilisations.

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

Les entrées expirées ne sont supprimées qu’au moment de leur lecture, ce qui rend le Map illimité dans une application à long terme qui accède à de nombreux URLs.

Un hook useMutation complémentaire

Les écritures sont déclenchées par l’appelant et non lors du montage. Ce hook suit l’état de chargement, les erreurs et les données, envoie un corps JSON, considère les codes autres que 2xx comme des échecs, et stocke ainsi que réjette les erreurs.

// 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 };
}

Dans le formulaire, le bouton est désactivé pendant la soumission et les erreurs s’affichent en dessous. Notez que onSubmit reçoit un événement, et non les données du formulaire ; par conséquent, le code réel doit appeler event.preventDefault() et construire le corps du formulaire, par exemple à l’aide de 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>
  );
}

Une mutation réussie n’affecte pas le cache de useFetch, de sorte que les listes associées restent obsolètes jusqu’à l’expiration de leur durée de vie ou tant que vous ne supprimez pas manuellement les éléments du cache.

Ce que vous sacrifiez

Réchargement en arrière-plan

React Query effectue un réchargement lorsque la fenêtre devient active. Vous pouvez approximer cela en supprimant l’entrée du cache lors de l’activation, mais cette action seule ne réexécute pas l’effet ; vous avez également besoin d’un déclencheur, comme un compteur d’état.

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

Mises à jour optimistes, outils de développement, pagination et invalidation

  • Mises à jour optimistes avec un retrait correct sont difficiles à mettre en œuvre, et la bibliothèque l’emporte clairement dans ce domaine.
  • Outils de développement affichant le cache, les requêtes et les temps d’exécution ; la version faite maison se contente de console.log.
  • useInfiniteQuery est bien conçu ; la pagination personnalisée exige nettement plus de travail.
  • Invalidation globale, comme la récupération forcée de toutes les requêtes “posts”, se réduit à un nettoyage manuel du cache.

Choix entre les deux

Préférez React Query pour l’invalidation déclenchée par des mutations, le rechargement en arrière-plan, le scroll infini ou une pagination complexe, les outils de développement, ou lorsque votre équipe le connaît déjà. Un petit hook convient lorsque les écrans se contentent principalement de récupérer et d’afficher des données, que le cache permanent ou à durée de vie limitée suffit, que zéro dépendance est important, et que l’application est de petite à moyenne taille. Si vous choisissez de rester avec cette bibliothèque, consultez comment structurer une couche de données TanStack Query.

Migration progressive

Modifiez un composant à la fois : changez l’import et l’appel du hook, ainsi que le renomage de isLoading en 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}`);

Lorsque plus rien n’importe cette bibliothèque, désinstallez-la.

npm uninstall @tanstack/react-query

Points clés

  • La plupart des opérations de récupération de données sont simples, et un Map avec des timestamps constitue un cache adapté pour des besoins simples.
  • AbortController constitue la solution native pour gérer les réponses obsolètes ou hors ordre.
  • Un crochet minimalise le cache des résultats, et non des requêtes en cours, ce qui fait que les montages simultanés génèrent toujours des appels dupliqués.
  • Chaque option de bibliothèque représente une surface d’interaction ; choisissez un outil en fonction de ses capacités réelles, et non par habitude.
  • Interrogez-vous pour savoir si le projet a besoin de quelque chose au-delà du chargement de base, puis choisissez en conséquence.
  • Lectures complémentaires