Les optimisations de rendu sont un concept avancé pour toute application. React Query est déjà livré avec d'excellentes optimisations et paramètres par défaut, et la plupart du temps, aucune optimisation supplémentaire n'est nécessaire. Les "re-rendus inutiles" sont un sujet sur lequel beaucoup de développeurs se focalisent, c'est pourquoi j'ai décidé de l'aborder. Mais je tiens à souligner encore une fois que, généralement, pour la plupart des applications, les optimisations de rendu n'ont probablement pas autant d'importance qu'on pourrait le penser. Les re-rendus sont bénéfiques. Ils garantissent que votre application est à jour. Je préfère largement avoir un "re-rendu inutile" plutôt qu'un "rendu manquant qui aurait dû être là". Pour plus d'informations sur ce sujet, consultez :
J'ai déjà beaucoup écrit sur les optimisations de rendu en décrivant l'option select dans #2: React Query Data Transformations. Cependant, "Pourquoi React Query re-rend mon composant deux fois alors que rien n'a changé dans mes données" est probablement la question à laquelle j'ai dû répondre le plus souvent (après peut-être : "Où puis-je trouver la documentation v2" 😅). Laissez-moi donc vous expliquer cela en détail.
Transition isFetching#
Je n'ai pas été totalement honnête dans l'exemple précédent quand j'ai dit que ce composant ne se re-rendrait que si la longueur des todos changeait :
export const useTodosQuery = (select) =>
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select,
})
export const useTodosCount = () =>
useTodosQuery((data) => data.length)
function TodosCount() {
const todosCount = useTodosCount()
return <div>{todosCount.data}</div>
}
Chaque fois que vous effectuez une récupération en arrière-plan, ce composant se re-rendra deux fois avec les informations de requête suivantes :
{ status: 'success', data: 2, isFetching: true }
{ status: 'success', data: 2, isFetching: false }
Cela est dû au fait que React Query expose beaucoup de méta-informations pour chaque requête, et isFetching en fait partie. Ce drapeau sera toujours true lorsqu'une requête est en cours. C'est très utile si vous souhaitez afficher un indicateur de chargement en arrière-plan. Mais c'est aussi inutile si vous ne le faites pas.
notifyOnChangeProps#
Pour ce cas d'utilisation, React Query propose l'option notifyOnChangeProps. Elle peut être définie au niveau de chaque observateur pour indiquer à React Query : Veuillez informer cet observateur uniquement si l'une de ces propriétés change. En définissant cette option sur ['data'], nous obtiendrons la version optimisée que nous recherchons :
export const useTodosQuery = (select, notifyOnChangeProps) =>
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select,
notifyOnChangeProps,
})
export const useTodosCount = () =>
useTodosQuery((data) => data.length, ['data'])
Vous pouvez voir cela en action dans l'exemple optimistic-updates-typescript dans la documentation.
Maintenir la synchronisation#
Bien que le code ci-dessus fonctionne correctement, il peut facilement se désynchroniser. Que se passe-t-il si nous voulons aussi réagir à l'erreur ? Ou si nous commençons à utiliser le drapeau isLoading ? Nous devons maintenir la liste notifyOnChangeProps synchronisée avec les champs que nous utilisons réellement dans nos composants. Si nous oublions de le faire, et que nous observons uniquement la propriété data, mais que nous recevons une erreur que nous affichons également, notre composant ne se re-rendra pas et sera donc obsolète. C'est particulièrement problématique si nous codons cela en dur dans notre hook personnalisé, car le hook ne sait pas ce que le composant utilisera réellement :
export const useTodosCount = () =>
useTodosQuery((data) => data.length, ['data'])
function TodosCount() {
// 🚨 nous utilisons error,
// mais nous ne sommes pas notifiés si error change !
const { error, data } = useTodosCount()
return (
<div>
{error ? error : null}
{data ? data : null}
</div>
)
}
Comme je l'ai mentionné dans l'avertissement au début, je pense que c'est bien pire qu'un occasionnel re-rendu inutile. Bien sûr, nous pouvons passer l'option au hook personnalisé, mais cela reste assez manuel et ressemble à du code répétitif. Y a-t-il un moyen de faire cela automatiquement ? Il s'avère que oui :
Tracked Queries#
Je suis particulièrement fier de cette fonctionnalité, étant donné qu'il s'agissait de ma première contribution majeure à la bibliothèque. Si vous définissez notifyOnChangeProps sur 'tracked', React Query suivra les champs que vous utilisez pendant le rendu et les utilisera pour calculer la liste. Cela optimisera exactement de la même manière que si vous spécifiez la liste manuellement, sauf que vous n'avez pas à y penser. Vous pouvez également activer cela globalement pour toutes vos requêtes :
const queryClient = new QueryClient({
defaultOptions: {
queries: {
notifyOnChangeProps: 'tracked',
},
},
})
function App() {
return (
<QueryClientProvider client={queryClient}>
<Example />
</QueryClientProvider>
)
}
Avec cela, vous n'aurez plus jamais à penser aux re-rendus. Bien sûr, le suivi des utilisations a aussi un peu de surcharge, alors assurez-vous de l'utiliser judicieusement. Il existe également quelques limitations aux requêtes trackées, c'est pourquoi il s'agit d'une fonctionnalité optionnelle :
- Si vous utilisez la déstructuration rest d'objet, vous observez effectivement tous les champs. La déstructuration normale est acceptable, évitez simplement ceci :
// 🚨 suivra tous les champs
const { isLoading, ...queryInfo } = useQuery(...)
// ✅ ceci est parfaitement correct
const { isLoading, data } = useQuery(...)
- Les requêtes trackées ne fonctionnent que "pendant le rendu". Si vous n'accédez aux champs que pendant les effets, ils ne seront pas suivis. C'est toutefois un cas limite en raison des tableaux de dépendances :
const queryInfo = useQuery(...)
// 🚨 ne suivra pas correctement data
React.useEffect(() => {
console.log(queryInfo.data)
})
// ✅ correct car le tableau de dépendances est accédé pendant le rendu
React.useEffect(() => {
console.log(queryInfo.data)
}, [queryInfo.data])
- Les requêtes trackées ne se réinitialisent pas à chaque rendu, donc si vous suivez un champ une fois, vous le suivrez pendant toute la durée de vie de l'observateur :
const queryInfo = useQuery(...)
if (someCondition()) {
// 🟡 nous suivrons le champ data si someCondition était vrai dans n'importe quel cycle de rendu précédent
return <div>{queryInfo.data}</div>
}
À partir de la v4, les requêtes trackées sont activées par défaut dans React Query, et vous pouvez désactiver cette fonctionnalité avec notifyOnChangeProps: 'all'.
Structural sharing#
Une optimisation de rendu différente, mais tout aussi importante, que React Query active par défaut est le structural sharing. Cette fonctionnalité garantit que nous conservons l'identité référentielle de nos data à chaque niveau. Par exemple, supposons que vous ayez la structure de données suivante :
[
{ "id": 1, "name": "Learn React", "status": "active" },
{ "id": 2, "name": "Learn React Query", "status": "todo" }
]
Maintenant, supposons que nous passions notre premier todo à l'état "done" et que nous effectuions une récupération en arrière-plan. Nous obtiendrons un json complètement nouveau de notre backend :
[
- { "id": 1, "name": "Learn React", "status": "active" },
+ { "id": 1, "name": "Learn React", "status": "done" },
{ "id": 2, "name": "Learn React Query", "status": "todo" }
]
React Query va alors tenter de comparer l'ancien état et le nouveau pour conserver autant que possible de l'état précédent. Dans notre exemple, le tableau todos sera nouveau, car nous avons mis à jour un todo. L'objet avec l'id 1 sera également nouveau, mais l'objet avec l'id 2 conservera la même référence que celle de l'état précédent - React Query va simplement le copier dans le nouveau résultat car rien n'a changé dedans.
Cela s'avère très pratique lors de l'utilisation de sélecteurs pour les souscriptions partielles :
// ✅ ne se re-rendra que si _quelque chose_ change dans le todo avec id:2
// grâce au structural sharing
const { data } = useTodo(2)
Comme je l'ai mentionné précédemment, pour les sélecteurs, le structural sharing sera effectué deux fois : Une fois sur le résultat renvoyé par queryFn pour déterminer si quelque chose a changé, puis une seconde fois sur le résultat de la fonction selector. Dans certains cas, particulièrement avec de très grands ensembles de données, le structural sharing peut devenir un goulot d'étranglement. Il ne fonctionne également que sur les données sérialisables en json. Si vous n'avez pas besoin de cette optimisation, vous pouvez la désactiver en définissant structuralSharing: false sur n'importe quelle requête.
Jetez un œil aux tests replaceEqualDeep si vous voulez en savoir plus sur ce qui se passe sous le capot.