Bienvenue dans la Partie 2 de "Ce que j'ai à dire sur React Query". Au fur et à mesure que je me suis impliqué dans la bibliothèque et sa communauté, j'ai observé plusieurs patterns qui font fréquemment l'objet de questions. Initialement, je voulais tous les décrire dans un seul grand article, mais j'ai finalement décidé de les diviser en parties plus digestes. La première concerne une tâche courante et importante : la transformation des données.
Data Transformation#
Soyons honnêtes - la plupart d'entre nous n'utilisent pas GraphQL. Si c'est votre cas, vous pouvez vous estimer heureux car vous avez le luxe de demander vos données dans le format que vous souhaitez.
Si vous travaillez avec REST en revanche, vous êtes limité par ce que le backend renvoie. Alors comment et où transformer au mieux les données lorsque l'on travaille avec React Query ? La seule réponse qui vaille en développement logiciel s'applique ici aussi :
Ça dépend.
Voici 3+1 approches pour transformer les données, avec leurs avantages et inconvénients respectifs :
0. Sur le backend#
C'est mon approche préférée, quand c'est possible. Si le backend renvoie les données exactement dans la structure souhaitée, nous n'avons rien à faire. Bien que cela puisse sembler irréaliste dans de nombreux cas, par exemple en travaillant avec des API REST publiques, c'est tout à fait possible dans les applications d'entreprise. Si vous contrôlez le backend et disposez d'un endpoint qui renvoie des données pour votre cas d'utilisation précis, privilégiez la livraison des données comme vous les attendez.
🟢 aucun travail côté frontend
🔴 pas toujours possible
1. Dans la queryFn#
La queryFn est la fonction que vous passez à useQuery. Elle doit retourner une Promise, et les données résultantes se retrouvent dans le cache de la requête. Mais cela ne signifie pas que vous devez absolument retourner les données dans la structure fournie par le backend. Vous pouvez les transformer avant :
const fetchTodos = async (): Promise<Todos> => {
const response = await axios.get('todos')
const data: Todos = response.data
return data.map((todo) => todo.name.toUpperCase())
}
export const useTodosQuery = () =>
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
Dans le frontend, vous pouvez alors travailler avec ces données "comme si elles étaient venues ainsi du backend". Nulle part dans votre code vous ne travaillerez avec des noms de tâches qui ne sont pas en majuscules. Vous n'aurez également pas accès à la structure d'origine. Si vous regardez les react-query-devtools, vous verrez la structure transformée. Si vous regardez la trace réseau, vous verrez la structure d'origine. Cela peut être déroutant, gardez-le à l'esprit.
De plus, React Query ne peut effectuer aucune optimisation ici. Chaque fois qu'une requête est exécutée, votre transformation s'exécutera. Si c'est coûteux, envisagez l'une des autres alternatives. Certaines entreprises disposent également d'une couche d'API partagée qui abstrait la récupération des données, vous n'aurez donc peut-être pas accès à cette couche pour effectuer vos transformations.
🟢 très "proche du backend" en termes de colocalisation
🟡 la structure transformée se retrouve dans le cache, vous n'avez donc pas accès à la structure d'origine
🔴 s'exécute à chaque requête
🔴 pas faisable si vous avez une couche d'API partagée que vous ne pouvez pas modifier librement
2. Dans la fonction de rendu#
Comme conseillé dans la Partie 1, si vous créez des hooks personnalisés, vous pouvez facilement y effectuer des transformations :
const fetchTodos = async (): Promise<Todos> => {
const response = await axios.get('todos')
return response.data
}
export const useTodosQuery = () => {
const queryInfo = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
return {
...queryInfo,
data: queryInfo.data?.map((todo) => todo.name.toUpperCase()),
}
}
Tel quel, cela s'exécutera non seulement à chaque fois que votre fonction fetch s'exécute, mais en réalité à chaque rendu (même ceux qui n'impliquent pas de récupération de données). Ce n'est probablement pas un problème, mais si c'en est un, vous pouvez optimiser avec useMemo. Faites attention à définir vos dépendances de manière aussi précise que possible. Les données dans queryInfo seront référentiellement stables sauf si quelque chose a réellement changé (auquel cas vous voulez recalculer votre transformation), mais le queryInfo lui-même ne le sera pas. Si vous ajoutez queryInfo comme dépendance, la transformation s'exécutera à nouveau à chaque rendu :
export const useTodosQuery = () => {
const queryInfo = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos
})
return {
...queryInfo,
// 🚨 ne faites pas ça - le useMemo ne sert à rien ici !
data: React.useMemo(
() => queryInfo.data?.map((todo) => todo.name.toUpperCase()),
[queryInfo]
),
// ✅ mémoisation correcte avec queryInfo.data
data: React.useMemo(
() => queryInfo.data?.map((todo) => todo.name.toUpperCase()),
[queryInfo.data]
),
}
}
C'est une bonne option, particulièrement si vous avez une logique supplémentaire dans votre hook personnalisé à combiner avec votre transformation de données. Gardez à l'esprit que les données peuvent potentiellement être undefined, donc utilisez le chaînage optionnel lorsque vous travaillez avec elles.
Depuis que React Query a activé les requêtes trackées par défaut depuis la v4, la propagation ...queryInfo n'est plus recommandée, car elle invoque des getters sur toutes les propriétés.
🟢 optimisable via useMemo
🟡 la structure exacte ne peut pas être inspectée dans les devtools
🔴 syntaxe un peu plus compliquée
🔴 les données peuvent être potentiellement undefined
🔴 non recommandé avec les requêtes trackées
3. Utilisation de l'option select#
La v3 a introduit des sélecteurs intégrés, qui peuvent également être utilisés pour transformer les données :
export const useTodosQuery = () =>
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (data) => data.map((todo) => todo.name.toUpperCase()),
})
Les sélecteurs ne seront appelés que si les données existent, vous n'avez donc pas à vous soucier de undefined ici. Les sélecteurs comme celui ci-dessus s'exécuteront également à chaque rendu, car l'identité de la fonction change (c'est une fonction inline). Si votre transformation est coûteuse, vous pouvez la mémoriser soit avec useCallback, soit en l'extrayant vers une référence de fonction stable :
const transformTodoNames = (data: Todos) =>
data.map((todo) => todo.name.toUpperCase())
export const useTodosQuery = () =>
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
// ✅ utilise une référence de fonction stable
select: transformTodoNames,
})
export const useTodosQuery = () =>
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
// ✅ mémorise avec useCallback
select: React.useCallback(
(data: Todos) => data.map((todo) => todo.name.toUpperCase()),
[]
),
})
De plus, l'option select peut également être utilisée pour s'abonner uniquement à certaines parties des données. C'est ce qui rend cette approche vraiment unique. Considérez l'exemple suivant :
export const useTodosQuery = (select) =>
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select,
})
export const useTodosCount = () =>
useTodosQuery((data) => data.length)
export const useTodo = (id) =>
useTodosQuery((data) => data.find((todo) => todo.id === id))
Ici, nous avons créé une API similaire à useSelector en passant un sélecteur personnalisé à notre useTodosQuery. Les hooks personnalisés fonctionnent toujours comme avant, car select sera undefined si vous ne le passez pas, donc l'état complet sera retourné.
Mais si vous passez un sélecteur, vous n'êtes maintenant abonné qu'au résultat de la fonction de sélection. C'est assez puissant, car cela signifie que même si nous mettons à jour le nom d'une tâche, notre composant qui ne s'abonne qu'au compteur via useTodosCount ne sera pas rendu à nouveau. Le compteur n'a pas changé, donc React Query peut choisir de ne pas informer cet observateur de la mise à jour 🥳 (Notez que c'est un peu simplifié ici et techniquement pas entièrement vrai - je parlerai plus en détail des optimisations de rendu dans la Partie 3).
🟢 meilleures optimisations
🟢 permet des abonnements partiels
🟡 la structure peut être différente pour chaque observateur
🟡 le partage structurel est effectué deux fois (j'en parlerai plus en détail dans la Partie 3)
C'est tout ce que j'ai pour aujourd'hui 👋.