L'Architecture en Microservices : Approfondissement et Schémas
Pourquoi passer d'un monolithe à une structure en microservice ?
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.
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.
=> Allez lire la doc pour plus de détails : https://tanstack.com/query/v3
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>
);
};
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.
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
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;
};
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';
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.
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 :
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.
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.
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.
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;
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);
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.