React Query Pratique

--- vues
Banner image for React Query Pratique

Lorsque GraphQL et en particulier Apollo Client sont devenus populaires vers 2018, beaucoup affirmaient qu'ils allaient complètement remplacer redux. La question Redux est-il déjà mort ? a souvent été posée.

Je me souviens distinctement ne pas comprendre pourquoi il y avait tant d'agitation à ce sujet. Pourquoi une bibliothèque de récupération de données remplacerait-elle votre gestionnaire d'état global ? Quel est le lien entre les deux ?

J'avais l'impression que des clients GraphQL comme Apollo ne faisaient que récupérer les données, de manière similaire à axios pour REST, et qu'il fallait évidemment rendre ces données accessibles à l'application via un gestionnaire d'état.

Je ne pouvais pas avoir plus tort.

État Client vs. État Serveur#

Apollo ne se contente pas de décrire quelles données vous voulez ni de les récupérer ; il fournit également un cache pour ces données du serveur. Ainsi, vous pouvez utiliser le même hook useQuery dans plusieurs composants : il n'y aura qu'un seul appel, et toutes les utilisations suivantes renverront les données depuis le cache.

Cela rappelle ce que nous (et probablement d'autres équipes) faisions principalement avec redux : récupérer des données du serveur et les rendre disponibles partout.

Mais nous avons souvent traité cet « état serveur » de la même façon que l'état client. Pourtant, dès qu'il s'agit d'état serveur (pensez à une liste d'articles que vous récupérez, ou aux détails d'un utilisateur à afficher…), votre application n'en est pas propriétaire. Vous empruntez simplement ces données au serveur pour les afficher. Le serveur en est le propriétaire.

Cette approche a changé ma façon de penser la gestion des données. Si nous pouvons nous appuyer sur le cache pour l'affichage de données qui ne nous appartiennent pas, il ne reste plus grand-chose à gérer comme véritable état client à partager dans toute l'application. C'est là que l'on comprend pourquoi beaucoup pensent qu'Apollo peut remplacer redux dans de nombreux cas.

React Query#

Je n'ai jamais eu l'occasion d'utiliser GraphQL. Nous avons déjà une API REST, nous ne rencontrons pas de problèmes de sur-récupération, tout fonctionne bien, etc. Il n'y a pas suffisamment de contraintes pour justifier un changement, d'autant qu'il faudrait adapter le backend, ce qui n'est pas trivial.

Pourtant, je trouvais la simplicité de la gestion des données côté frontend (avec gestion des états de chargement et d'erreur) très séduisante. Si seulement il y avait un équivalent pour REST dans React…

Bienvenue à React Query.

Créée par Tanner Linsley fin 2019, React Query reprend les bonnes idées d'Apollo et les applique à REST. Elle fonctionne avec n'importe quelle fonction retournant une promesse et adopte la stratégie de cache “stale-while-revalidate”. Grâce à des paramètres par défaut très judicieux, la bibliothèque tente de garder vos données aussi fraîches que possible tout en les affichant rapidement, offrant souvent une impression de quasi-instantanéité et donc une excellente expérience utilisateur. Elle est également très flexible et autorise diverses personnalisations lorsque les paramètres par défaut ne suffisent pas.

Cet article n'est pas une introduction à React Query.

La documentation officielle explique très bien les guides et concepts, il existe aussi des vidéos issues de nombreuses conférences, et Tanner propose un cours Essentials pour vous familiariser avec la bibliothèque.

Mise à jour

J'ai travaillé sur un tout nouveau cours avec ui.dev. Si vous appréciez le contenu que je produis, vous aimerez query.gg.

Bannière React Query

Je souhaite plutôt me concentrer sur des conseils pratiques qui vont au-delà de la documentation, utiles lorsque vous travaillez déjà avec la bibliothèque. Ce sont des choses que j'ai apprises ces derniers mois en l'utilisant activement au travail et en participant à la communauté React Query, répondant aux questions sur Discord et GitHub Discussions.

Les paramètres par défaut expliqués#

Je pense que les paramètres par défaut de React Query sont bien choisis, mais ils peuvent parfois surprendre, surtout au début.

Tout d'abord, React Query n'invoque pas queryFn à chaque re-rendu, même avec staleTime à zéro par défaut. Votre application peut se re-rendre pour diverses raisons à tout moment ; déclencher un fetch à chaque re-rendu serait insensé !

Codez toujours comme s'il y avait de nombreux re-rendus. J'appelle cela la “résilience au re-rendu”.

Tanner Linsley

Si vous observez un refetch inattendu, c'est probablement dû au fait que votre fenêtre vient de reprendre le focus et que React Query exécute refetchOnWindowFocus. C'est une fonctionnalité excellente pour la production : si l'utilisateur bascule sur un autre onglet, puis revient, React Query lance un rafraîchissement en arrière-plan et met à jour les données si elles ont changé entre-temps sur le serveur. Tout cela se fait sans afficher de spinner de chargement, et votre composant ne se re-rendra pas si les données sont les mêmes qu'auparavant.

En développement, cela peut arriver plus souvent, car passer du DevTools du navigateur à votre application déclenchera aussi le refetch.

Mise à jour

Depuis React Query v5, refetchOnWindowFocus n'écoute plus l'événement focus : seul l'événement visibilitychange est utilisé. Cela évite des re-fetch involontaires en mode développement, tout en gardant le même comportement la plupart du temps en production. Cela corrige aussi différents problèmes décrits ici.

Ensuite, il existe parfois une confusion entre gcTime et staleTime. Voici un bref éclaircissement :

  • staleTime : la durée pendant laquelle une requête reste “fraîche” avant de devenir “obsolète”. Tant qu'elle est fraîche, aucune requête réseau n'a lieu, car on lit uniquement depuis le cache ! Une fois la requête obsolète (par défaut immédiatement), les données sont lues depuis le cache, mais un refetch en arrière-plan peut se produire sous certaines conditions.

  • gcTime : la durée pendant laquelle les requêtes inactives restent en cache avant d'être supprimées. Par défaut, 5 minutes. Les requêtes passent en état inactif dès lors qu'aucun composant n'observe plus ces données (tous les composants utilisant cette requête se sont démontés). La plupart du temps, si vous voulez modifier un paramètre, c'est staleTime qu'il faut ajuster. J'ai rarement eu besoin de toucher à gcTime. La documentation donne aussi un bon exemple.

Mise à jour

gcTime s'appelait auparavant cacheTime, mais il a été renommé en v5 pour mieux refléter son rôle.

Utilisez les DevTools de React Query#

Elles vous aideront énormément à comprendre l'état d'une requête. Vous verrez aussi quelles données sont stockées dans le cache, ce qui facilite le débogage. De plus, j'aime réduire la vitesse réseau via les DevTools pour mieux repérer les refetch en arrière-plan, car les serveurs de développement sont souvent très rapides.

Traitez la query key comme un tableau de dépendances#

Je fais référence au tableau de dépendances du hook useEffect. Pourquoi sont-ils similaires ?

Parce que React Query déclenche un refetch lorsque la query key change. Très souvent, lorsqu'on passe un paramètre à la queryFn, on souhaite relancer une requête lorsque la valeur change. Au lieu de gérer manuellement les appels, on peut simplement inclure le paramètre dans la query key :

feature/todos/queries.ts
type State = 'all' | 'open' | 'done'
type Todo = {
  id: number
  state: State
}
type Todos = ReadonlyArray<Todo>

const fetchTodos = async (state: State): Promise<Todos> => {
  const response = await axios.get(`todos/${state}`)
  return response.data
}

export const useTodosQuery = (state: State) =>
  useQuery({
    queryKey: ['todos', state],
    queryFn: () => fetchTodos(state),
  })

Ici, imaginons que notre UI affiche une liste de tâches avec un filtre. On stocke la valeur du filtre dans un état local ; dès que l'utilisateur change le filtre, le hook se met à jour et React Query relance la requête automatiquement. Nous synchronisons ainsi la sélection de l'utilisateur avec la fonction de requête, de manière analogue à un tableau de dépendances d'un useEffect.

Une nouvelle entrée de cache#

Puisque la query key agit comme clé de cache, vous aurez une nouvelle entrée de cache quand vous passerez de 'all' à 'done', ce qui peut provoquer un écran de chargement la première fois que vous changez de filtre. Pour éviter cela, vous pouvez pré-remplir la cache via initialData. Prenons l'exemple précédent :

pre-filtering
type State = 'all' | 'open' | 'done'
type Todo = {
  id: number
  state: State
}
type Todos = ReadonlyArray<Todo>

const fetchTodos = async (state: State): Promise<Todos> => {
  const response = await axios.get(`todos/${state}`)
  return response.data
}

export const useTodosQuery = (state: State) => {
  useQuery({
    queryKey: ['todos', state],
    queryFn: () => fetchTodos(state),
    initialData: () => {
      const allTodos = queryClient.getQueryData<Todos>([
        'todos',
        'all',
      ])
      const filteredData =
        allTodos?.filter((todo) => todo.state === state) ?? []

      return filteredData.length > 0 ? filteredData : undefined
    },
  })

Désormais, à chaque fois que l'utilisateur bascule d'un état à un autre, si nous n'avons pas encore les données pour 'done', nous puisons dans le cache de 'all'. Ainsi, on affiche immédiatement certaines tâches “done” pendant que la requête en arrière-plan se termine. C'est un réel bonus UX pour quelques lignes de code.

Séparez l'état serveur de l'état client#

C'est cohérent avec putting-props-to-use-state, un article que j'ai écrit récemment : lorsque vous obtenez des données de useQuery, évitez de les placer dans un état local. Sinon, vous perdez les mises à jour en arrière-plan gérées par React Query, puisque votre “copie” locale ne se mettra pas à jour.

C'est acceptable si, par exemple, vous récupérez des valeurs par défaut pour un formulaire et que vous affichez le formulaire après coup. Dans ce cas, les mises à jour en arrière-plan ne sont pas très utiles, et si vous ne le faites pas exprès, pensez à ajuster staleTime :

initial-form-data
const App = () => {
  const { data } = useQuery({
    queryKey: ['key'],
    queryFn,
    staleTime: Infinity,
  })

  return data ? <MyForm initialData={data} /> : null
}

const MyForm = ({ initialData }) => {
  const [data, setData] = React.useState(initialData)
  // ...existing code...
}

Ce concept est un peu plus compliqué si vous autorisez l'édition de données affichées, mais il offre de nombreux avantages. J'ai préparé un petit exemple codesandbox :

L'idée importante : ne copiez jamais la valeur que vous recevez de React Query dans un état local, pour bénéficier des mises à jour en continu. Pas de copie locale !

L'option enabled est très puissante#

Le hook useQuery offre de nombreuses options de personnalisation. enabled est particulièrement utile et permet plusieurs scénarios :

Dependent Queries
Lancer une requête (Query A) puis n'exécuter une seconde (Query B) qu'après avoir obtenu le résultat de la première.

• Activer/désactiver des requêtes
Par exemple, on met en place un rafraîchissement régulier (refetchInterval), mais on le suspend temporairement si une modal est ouverte.

• Attendre une saisie utilisateur
On a des critères de filtre dans la query key, mais on désactive la requête tant que l'utilisateur n'a pas validé.

• Désactiver une requête après saisie utilisateur
Par exemple, si l'utilisateur entre un brouillon qui doit primer sur les données du serveur.

N'utilisez pas le queryCache comme gestionnaire d'état local#

Si vous modifiez le queryCache (queryClient.setQueryData), faites-le uniquement pour des mises à jour optimistes ou pour écrire des données venant du serveur après une mutation. N'oubliez pas qu'un refetch en arrière-plan peut écraser ces données. Il est donc préférable d'utiliser useState, Zustand, Redux ou autre pour gérer l'état purement local.

Créez des hooks personnalisés#

Même pour envelopper un seul appel à useQuery, un hook personnalisé est souvent profitable, car :

• Vous sortez la logique de fetch du code UI tout en la maintenant proche de votre hook. • Vous centralisez l'usage d'une query key et de ses types au même endroit. • Si vous devez adapter la configuration ou transformer les données, vous ne modifiez qu'un seul fichier. • Vous avez déjà vu un exemple dans la section Todos ci-dessus.


J'espère que ces conseils pratiques vous aideront à démarrer avec React Query. Bonne exploration !