Resources
Aug 19, 2024

React query : Comment organiser son code

React Query (Désormais Tanstack Query) est une bibliothèque puissante pour les applications React qui permet de gérer de manière simplifiée les états complexes liés aux requêtes de données asynchrones.

React query : Comment organiser son code

Que vous ayez à interagir avec une API REST, GraphQL ou d'autres sources de données asynchrones, React Query offre une solution robuste et efficace pour gérer le fetching, la mise à jour, la mise en cache et la synchronisation des données.

Depuis que je l’ai essayé la première fois, je l’intègre dans quasiment tous les projets front.

Pourquoi utiliser React Query ?

  1. Simplification de la Gestion des États Complexes:
    • Gestion automatique des états loading, error, success.
    • Réduction significative du code nécessaire pour gérer les états asynchrones.
  2. Mise en Cache et Synchronisation:
    • Mécanisme de mise en cache intelligent qui minimise les appels réseau redondants.
    • Révalidation automatique des données vieillissantes
  3. Optimisation des Performances:
    • Optimisation des performances grâce à la stratégie de préfetching.
    • Invalidations et refetches automatiques basés sur la mutabilité des données et les hooks de lifecycle de React.
  4. API Intuitive et Flexible:some text
    • Une API qui suit les meilleures pratiques du développement React.
    • Support complet des requêtes dynamiques et pagination.
=> Allez lire la doc pour plus de détails : https://tanstack.com/query/v3

Installation et Configuration de Base

Pour commencer avec React Query, vous pouvez l'installer via npm ou yarn:

npm install react-query

# ou

yarn add react-query

Ensuite, vous devez importer et configurer le QueryClient et le QueryClientProvider dans votre application:

import { QueryClient, QueryClientProvider } from 'react-query';

const queryClient = new QueryClient();

const App = () => {
  return (
    <QueryClientProvider client={queryClient}>
      <YourComponent />
    </QueryClientProvider>
  );
};

Fetching de Données

L'une des tâches les plus courantes consiste à récupérer des données depuis une API. React Query vous permet de le faire simplement avec le hook useQuery:

import { useQuery } from 'react-query';

const fetchTodos = async () => {
  const response = await fetch('https://dummyjson.com/todos');
  if (!response.ok) throw new Error('Network response was not ok');
  return response.json();
};

const TodoList = () => {
  const { data, error, isLoading } = useQuery('todos', fetchTodos);

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <ul>
      {data.map(todo => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
};

Mutation de Données

Pour les opérations de mutation (ajout, mise à jour, suppression), React Query fournit le hook useMutation:

import { useMutation, useQueryClient } from 'react-query';

const addTodo = async (newTodo) => {
  const response = await fetch('https://dummyjson.com/todos', {
    method: 'POST',
    body: JSON.stringify(newTodo),
    headers: {
      'Content-Type': 'application/json',
    },
  });
  if (!response.ok) throw new Error('Network response was not ok');
  return response.json();
};

const AddTodo = () => {
  const queryClient = useQueryClient();
  const mutation = useMutation(addTodo, {
    onSuccess: () => {
      queryClient.invalidateQueries('todos');
    },
  });

  const handleAdd = () => {
    mutation.mutate({ title: 'New Todo', completed: false });
  };

  return (
    <div>
      <button onClick={handleAdd}>Add Todo</button>
    </div>
  );
};

Architecture adaptée pour scaler

Très vite, si vous utilisez ces bouts de code dans toute votre app, vous allez vous retourner avec des composant difficiles à lire. Je vous partage donc comment j’organise mon code pour utiliser cette lib de manière propre et scalable.

I - Création d’un dossier /api

Il est toujours bon de gérer les appels vers l’extérieur depuis votre app à un seul endroit. Je créer donc un dossier /api à la racine de mon projet.

Si vous utilisez NextJS, /api est un nom réservé. Utilisez un autre nom

1 - /dummyjson

Dans le dossier /api, je créer systématiquement un sous dossier par API externe. Dans notre cas /dummyjson

Dans ce sous-dossier, je créer un fichier queries.ts

C'est dans ce fichier que nous allons définir toutes les routes exposées par notre API. 

/api/dummyjson/queries.ts

import { type QueryKey } from '@tanstack/react-query';
import { Todo, CreateTodoRequest, UpdateTodoRequest } from './models'


// Par commodité, je mets l'url en dur ici. Mais notez bien que ce n'est pas une bonne pratique. Il faut mettre l'url en variable d'environnement et si possible mettre en place un proxy. 
const API_URL = 'https://dummyjson.com/todos';

const fetcher = async <R, V = object>(
  query: string,
  variables?: V,
  method: RequestInit['method'] = 'GET'
) =>
  await fetch(`${API_URL}/${query}`, {
    method,
    headers: {
      'Content-Type': 'application/json',
    },
    ...(variables ? { body: JSON.stringify(variables) } : {}),
  }).then(async (response) => {
    try {
      const json = await response.json().catch(() => ({}));
      if (response.ok) {
        return json as R;
      }
      throw new Error(JSON.stringify({ status: response.status, message: json }));
    } catch (error) {
      return await Promise.reject(error);
    }
  });

//Cette fonction va nous permettre de retourner les noms de nos routes de manière typée. 
export const getQueryKey = (queryName: string, ...variables: any[]) =>
  [queryName, ...variables] as QueryKey;

export const getTodos = async () =>
  await fetcher<Todo[]>('');

export const getTodo = async (todoId: number) =>
  await fetcher<Todo>(`${todoId}`);

export const createTodo = async (data: CreateTodoRequest) =>
  await fetcher<Todo, CreateTodoRequest>('', data, 'POST');

export const updateTodo = async (todoId: number, data: UpdateTodoRequest) =>
  await fetcher<Todo, UpdateTodoRequest>(`${todoId}`, data, 'PUT');

export const deleteTodo = async (todoId: number) =>
  await fetcher<void>(`${todoId}`, {}, 'DELETE');

Vous pouvez également créer un fichier models.ts dans lequel vous mettrez tous les types issues de l'API. 

Il existe plusieurs outils pour générer les types du backend (openapi-generator par exemple)

/api/dummyjson/models.ts

type Todo = {
  id: number;
  todo: string;
  completed: boolean;
  userId: number;
};

type CreateTodoRequest = {
  todo: string;
  completed: boolean;
  userId: number;
};

type UpdateTodoRequest = {
  id: number;
  todo?: string;
  completed?: boolean;
  userId?: number;
};

2 - QueryClient.ts

Maintenant que nous pouvons communiquer avec notre API, nous pouvons désormais configurer notre client React Query.

Commençons par créer un fichier que nous appellerons queryClient.ts

Ce fichier queryClient.ts va nous servir à configurer notre client React Query et gérer les erreurs des requêtes et des mutations ainsi que certaines options de comportement par défaut des requêtes.

/api/queryClient.ts

import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query';
import { t } from 'i18next';
import { getQueryKey } from './dummyjson/queries';

const _handleError = (
  error: {
    message: string;
  },
  type: 'mutation' | 'query'
) => {
  const err = JSON.parse(error?.message);
  // const errMessage = err?.message;
  const errorCode = err?.status;

  if (errorCode === 503 || errorCode === 504 || errorCode === 502) {
    console.error('Service unavailable');
    const message = t('service-unavailable');
    const event = new CustomEvent('snackbar', { detail: { message } });
    document.dispatchEvent(event);
  } else if (type === 'mutation') {
    const message = t('an-error-occured');
    const event = new CustomEvent('snackbar', { detail: { message } });
    document.dispatchEvent(event);
  }
};

/**
 * Handle errors from API and display snackbar
 */
const handleQueryError: QueryCache['config']['onError'] = (error: { message: string }) => {
  try {
    _handleError(error, 'query');
  } catch (error) {
    console.error(error);
  }
  return false;
};

/**
 * Handle errors from API and display snackbar
 * @param error
 * @param query
 * @returns
 */
const handleMutationError: MutationCache['config']['onError'] = (error: { message: string }) => {
  try {
    _handleError(error, 'mutation');
  } catch (error) {
    console.error(error);
  }
  return false;
};

export const queryClient = new QueryClient({
  mutationCache: new MutationCache({
    onError: handleMutationError,
  }),
  queryCache: new QueryCache({
    onError: handleQueryError,
  }),
  defaultOptions: {
    queries: {
      gcTime: 1000 * 60 * 60 * 24 * 30, // 30 days
      retry: (failureCount, error: Error) => {
        try {
          const errMessage = JSON.parse(error?.message.replace('Error: Error: ', ''));
          if (errMessage?.status === 404) return false;
          if (errMessage?.status === 401) return false;
          if (errMessage?.status === 403) return false;
          if (errMessage?.status === 418) return false;
          else if (failureCount < 2) return true;
          else return false;
        } catch (error) {
          return false;
        }
      },
      refetchOnMount: false,
      refetchOnWindowFocus: false,
      refetchOnReconnect: false,
    },
  },
});

Voici une explication détaillée de chaque section du fichier :

1. Imports

import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query';
import { getQueryKey } from './dummyjson/queries';
  • @tanstack/react-query: Importation des classes principales pour gérer les requêtes et les mutations.
  • getQueryKey: Une fonction utilitaire qui retourne le nom des requêtes de manière typée

2. _handleError Function

const _handleError = (
  error: {
    message: string;
  },
  type: 'mutation' | 'query'
) => {
  const err = JSON.parse(error?.message);
  const errorCode = err?.status;

  if (errorCode === 503 || errorCode === 504 || errorCode === 502) {
    console.error('Service unavailable');
    const message = t('service-unavailable');
    const event = new CustomEvent('snackbar', { detail: { message } });
    document.dispatchEvent(event);
  } else if (type === 'mutation') {
    const message = t('an-error-occured');
    const event = new CustomEvent('snackbar', { detail: { message } });
    document.dispatchEvent(event);
  }
};

Cette fonction privée _handleError gère les erreurs des requêtes et des mutations. Selon le code d'erreur et le type (mutation ou query), elle affiche un message via un événement personnalisé snackbar.

  • Erreur 502, 503, 504: Indiquent que le service est indisponible.
  • Autres erreurs: Affiche un message d'erreur générique généré grâce à i18next

3. handleQueryError et handleMutationError


const handleQueryError: QueryCache['config']['onError'] = (error: { message: string }) => {
  try {
    _handleError(error, 'query');
  } catch (error) {
    console.error(error);
  }
  return false;
};


const handleMutationError: MutationCache['config']['onError'] = (error: { message: string }) => {
  try {
    _handleError(error, 'mutation');
  } catch (error) {
    console.error(error);
  }
  return false;
};

5. Configuration du query client

export const queryClient = new QueryClient({
  mutationCache: new MutationCache({
    onError: handleMutationError,
  }),
  queryCache: new QueryCache({
    onError: handleQueryError,
  }),
  defaultOptions: {
    queries: {
      gcTime: 1000 * 60 * 60 * 24 * 30, // 30 days
      retry: (failureCount, error: Error) => {
        try {
          //Parsing de l'objet erreur. Peu varier selon les API
          const errMessage = JSON.parse(error?.message.replace('Error: Error: ', ''));
          if (errMessage?.status === 404) return false;
          if (errMessage?.status === 401) return false;
          if (errMessage?.status === 403) return false;
          if (errMessage?.status === 418) return false;
          else if (failureCount < 2) return true;
          else return false;
        } catch (error) {
          return false;
        }
      },
    },
  },
});

Ce bloc configure le QueryClient avec les comportements suivants :

  • Caches des erreurs: Gère les erreurs avec handleMutationError et handleQueryError.
  • Options par défaut pour les requêtes:
    • gcTime: Définit le temps de collecte du caches à 30 jours.
    • retry: Définit la politique de retry (réessayer) des requêtes échouées.some text
      • Ne réessaie pas pour certaines erreurs spécifiques (404, 401, 403, 418).
      • Réessaie au maximum 2 fois pour d'autres erreurs.
    • refetchOnMount, refetchOnWindowFocus, refetchOnReconnect: Désactivés pour éviter les refetchs automatiques.

Conclusion

Ce fichier configure les comportements des requêtes et des mutations définies avec React Query pour gérer les erreurs de manière centralisée et afficher les messages d'erreur via un système de snackbar. Il définit également des options par défaut pour les requêtes, comme la politique de retry et le temps de collecte du cache.

De cette façon, vous gérez vos erreurs en un seul endroit. Nous verrons plus bas comment afficher une snackbar.

3 - Provider

Dans un dossier /providers, créez un fichier QueryClientProvider.tsx

/providers/QueryClientProvider.tsx

import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { type PropsWithChildren } from 'react';
import { queryClient } from '@/apis/queryClient';

const persister = createSyncStoragePersister({
  storage: window.localStorage,
});

const QueryClientProvider = ({ children }: PropsWithChildren) => (
  <PersistQueryClientProvider client={queryClient} persistOptions={{ persister }}>
    {children}
  </PersistQueryClientProvider>
);

export default QueryClientProvider;

Il faudra importer ce provider au niveau du fichier de base de votre app. 

4 - Les hooks

Il ne nous reste plus qu'a définir les hooks React Query pour ensuite utiliser nos données. 

Je vous conseil de créer un fichier de hooks par objet. 

Ici nous gérons les todos. Mais nous pourrions créer aussi un fichier pour les posts, les users ... etc.

/api/dummyjson/todos.ts

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
  getTodos,
  getTodo,
  createTodo,
  updateTodo,
  deleteTodo,
  getQueryKey,
} from '../api/dummyjson/queries'; // Assurez-vous de bien définir le chemin correct pour cet import
import type { Todo, CreateTodoRequest, UpdateTodoRequest } from '../api/dummyjson/models';

export const useTodos = () => {
  return useQuery(getQueryKey('getTodos'), getTodos);
};

export const useTodo = (todoId: number) => {
  return useQuery(getQueryKey('getTodo', todoId), () => getTodo(todoId));
};

export const useCreateTodo = () => {
  const queryClient = useQueryClient();
  return useMutation(createTodo, {
    onMutate: async () => {
      // Optional: cancel ongoing fetches or updates if relevant
    },
    onSettled: () => {
      queryClient.invalidateQueries(getQueryKey('getTodos'));
    },
  });
};

export const useUpdateTodo = (todoId: number) => {
  const queryClient = useQueryClient();
  return useMutation((data: UpdateTodoRequest) => updateTodo(todoId, data), {
    onMutate: async () => {
      // Optional: cancel ongoing fetches or updates if relevant
    },
    onSettled: () => {
      queryClient.invalidateQueries(getQueryKey('getTodos'));
      queryClient.invalidateQueries(getQueryKey('getTodo', todoId));
    },
  });
};

export const useDeleteTodo = (todoId: number) => {
  const queryClient = useQueryClient();
  return useMutation(() => deleteTodo(todoId), {
    onMutate: async () => {
      // Optional: cancel ongoing fetches or updates if relevant
    },
    onSettled: () => {
      queryClient.invalidateQueries(getQueryKey('getTodos'));
    },
  });
};

Ce fichier nous permet de gérer facilement la donnée des todos. Notez les callbacks utilisés dans les mutations. Cela nous permet d'invalider le cache nécessaire lorsque l'on sait qu'une mutation va l'impacter. Ici, lorsque nous mettons à jour à jour une todo, nous savons que la liste des todos et le détail de la todo en question aurons leur cache obsolète. queryClient.invalidateQueries va forcer React Query à refetch la donnée concernée lorsqu'elle sera demandée.

5 - Utilisation dans les composants

Voilà, il ne reste plus qu'à manipuler la donnée dans les composants grâce aux hooks que nous venons de créer. 

import React from 'react';
import { useTodos, useCreateTodo } from './hooks/useTodo';

const TodoList = () => {
  const { data: todos, isLoading } = useTodos();
  const createTodoMutation = useCreateTodo();

  if (isLoading) {
    return <div>Loading...</div>;
  }

  const handleAddTodo = () => {
    createTodoMutation.mutate({ todo: 'New Todo', completed: false, userId: 1 });
  };

  return (
    <div>
      <button onClick={handleAddTodo}>Add Todo</button>
      <ul>
        {todos?.map((todo) => (
          <li key={todo.id}>{todo.todo}</li>
        ))}
      </ul>
    </div>
  );
};

export default TodoList;

6 - La snackbar

Vous vous souvenez de la création du CustomEvent dans la gestion d'erreur ? 

/api/queryClient.ts

const event = new CustomEvent('snackbar', { detail: { message } });
document.dispatchEvent(event);

Ce bout de code nous permet d'écouter de n'importe où la présence d'une erreur venant de l'API.

Voici ci-dessous un exemple d'utilisation de cet évènement.

Créez un fichier /providers/SnackbarProvider.tsx et importez le ensuite dans le fichier principale de votre projet. 

// Provider to handle error messages with Snackbar

import { createContext, useContext, useEffect, useState, type PropsWithChildren } from 'react';
import { Modal } from '@/components';


export const SnackbarProvider = ({ children }: PropsWithChildren) => {
  const [snackbarMessage, setSnackbarMessage] = useState<string | null>(null);
  const [snackbarVisible, setSnackbarVisible] = useState<boolean>(false);

  const value = {
    snackbarMessage,
    setSnackbarMessage,
  };

  useEffect(() => {
    if (snackbarMessage) {
      setSnackbarVisible(true);
    }
  }, [snackbarMessage]);

  useEffect(() => {
    document.addEventListener('snackbar', (event) => {
      setSnackbarMessage((event as CustomEvent).detail.message as string);
    });
    // Remove event listener on unmount
    return () => {
      document.removeEventListener('snackbar', () => {});
    };
  }, []);

  return (
    <SnackbarContext.Provider value={value}>
      {children}
      <Modal
        isOpen={snackbarVisible}
        onDismiss={() => {
          setSnackbarMessage(null);
          setSnackbarVisible(false);
        }}>
        <p className="text-left text-xs text-black">{snackbarMessage ?? ''}</p>
      </Modal>
    </SnackbarContext.Provider>
  );
};

export const useSnackbar = () => useContext(SnackbarContext);

Conclusion

L'architecture que vous propose vous permettra d'itérer sur des projets complexes faisant appel à plusieurs API sans perdre en lisibilité dans vos composants. Elle est le fruit de 5 ans d'expérience sur cette librairie dont j'ai beaucoup de mal à me passer.