Accueil / Articles / Éliminer les doublons dans les requêtes ORM lors d’une rendu Next.js à l’aide de React cache()

Éliminer les doublons dans les requêtes ORM lors d’une rendu Next.js à l’aide de React cache()

Découvrez pourquoi les composants serveur colocalisés peuvent interroger le même enregistrement plusieurs fois par requête, comment le vérifier, et comment React cache() résout ce problème sans avoir recours à l’exploration des props.

2929 mots

Une route Next.js peut sembler rapide dans le navigateur, tandis que sa base de données répond silencieusement à la même question trois ou quatre fois pour chaque affichage de page. La cause n’est rarement une requête lente ; il s’agit plutôt d’une recherche ordinaire répétée à travers des limites de rendu qui semblent indépendantes dans votre code : generateMetadata(), la page, un fil d’Ariane, un composant serveur imbriqué. Cet article montre comment cette duplication apparaît, comment prouver qu’elle se produit réellement, et comment l’éliminer à l’aide de cache() de React tout en gardant l’accès aux données près des composants qui en ont besoin. Il établit également une distinction claire entre cette mémorisation par requête et le cache persistant, qui répond à une question complètement différente.

Comment un affichage de page se transforme en quatre recherches

Prenons une route de produit dynamique :

/products/[slug]

Plusieurs parties de cette route ont besoin du même produit. generateMetadata() exige son nom et sa description pour l’en-tête du document. La page souhaite obtenir l’ensemble des informations sur le produit. Les chemins d’accès nécessitent la catégorie correspondante. Un composant serveur imbriqué peut afficher le prix ou l’état des stocks. Chacun de ces utilisateurs peut raisonnablement charger ce dont il a besoin par lui-même. La fonction de métadonnées s’y prend comme suit :

export async function generateMetadata({
  params,
}: PageProps<'/products/[slug]'>) {
  const { slug } = await params
  const product = await getProduct(slug)
return {
    title: product.name,
  }
}

Le composant de page fait la même chose :

export default async function ProductPage({
  params,
}: PageProps<'/products/[slug]'>) {
  const { slug } = await params
  const product = await getProduct(slug)
return <ProductDetails product={product} />
}

Et plus profondément dans l’arborescence, un autre composant serveur appelle indépendamment :

const product = await getProduct(slug)

D’un point de vue de conception de composants, c’est tout à fait correct. Chaque élément d’interface demande ses données là où il les utilise, et les responsabilités restent claires. Le problème ne se manifeste que lorsqu’on examine l’autre aspect : les journaux des requêtes de base de données ou les métriques des API en amont. Une seule demande entrante peut générer plusieurs recherches identiques de produits. Une réponse est envoyée au navigateur, mais sa création peut avoir coûté à la base de données quatre allers-retours.

Ce que Next.js déduplique déjà, et ce qu’il ne fait pas

Il existe une distinction importante à bien comprendre avant de modifier quoi que ce soit. Next.js mémorise automatiquement les requêtes fetch natives identiques effectuées lors du rendu de l’arbre des composants React, et sa documentation précise que cela s’applique aux fonctions generateMetadata, aux layouts, aux pages ainsi qu’aux composants serveur. Si getProduct() est basé sur fetch, ces appels dupliqués peuvent déjà être regroupés en un seul.

Lorsque les données proviennent d’une source autre que fetch (un ORM, un pilote de base de données, un SDK tiers), il n’y a pas de mémorisation automatique. Dans ce cas, Next.js recommande l’utilisation de cache() de React pour partager les tâches répétitives au sein d’une même requête.

L’idée fondamentale mérite d’être exprimée clairement : le coût en termes de performance n’est souvent pas dû à une requête coûteuse, mais plutôt à une opération apparemment peu dispendieuse qui est répétée à travers des frontières que le framework rend faciles à composer de manière indépendante. La solution ne consiste pas à regrouper toutes les requêtes dans un seul composant parent géant. Il s’agit plutôt de donner à ces accès aux données répétés une identité unique partagée.

La colocalisation rend le travail répétitif invisible

Avec les Server Components, il est naturel de placer l’accès aux données à côté de son consommateur. Un composant qui affiche les détails d’un produit peut charger ce dernier directement, et les chemins de navigation n’ont pas besoin de recevoir un objet produit volumineux transmis à travers des composants non liés, simplement parce qu’un composant ancêtre l’a chargé en premier.

Sans cette liberté, la méthode habituelle pour éviter les tâches redondantes consiste à placer tout le chargement en haut de la route et à transmettre les résultats vers le bas à travers chaque couche intermédiaire. Cela fonctionne, mais cela lie des composants qui n’ont en réalité aucun rapport entre eux. L’alternative consistant à les placer côte à côte est que chaque composant serveur dépende d’une fonction de données réutilisable :

const product = await getProduct(slug)

Cela permet à chaque exigence d’être située à côté de son consommateur. La question restante est de savoir si ces appels partagent réellement une seule opération sous-jacente.

Si ils émettent finalement des requêtes fetch natives identiques, React les mémorise au sein de l’arbre des composants. Les documents de Next.js citent précisément cela comme justification pour charger les données dans le composant qui les utilise plutôt qu’en haut de la route avec des props transmis vers le bas. Une appel direct à un ORM tel que :

db.product.findUnique({
  where: { slug },
})

Il ne subit aucun de ces comportements simplement parce que deux composants lui transmettent le même identifiant. Pour React, il s’agit simplement d’une fonction asynchrone arbitraire qui s’exécutera chaque fois qu’elle est appelée, à moins de lui fournir une identité mémorisée.

Les limites des composants définissent qui est responsable d’une partie de l’interface utilisateur. Elles ne disent rien sur celui qui gère un travail avec des données répétées. La co-localisation n’est pas l’erreur ; supposer qu’il y a co-localisation implique en revanche une déduplication.

Mesurez avant de mémoriser

La mémorisation est une réponse à une duplication observée, et non à une simple suspicion. Voici un exemple d’appel identique dans quatre fichiers différents :

await getProduct(slug)

Cela ne prouve pas que la base de données a été consultée quatre fois. Dans Next.js, cette distinction est particulièrement importante, car le framework peut déjà éliminer les appels fetch identiques. Les versions récentes de Next.js offrent également un journalisation en développement pour les activités fetch côté serveur, ce qui vous permet de voir ce qui est réellement demandé (consultez la documentation actuelle pour savoir comment l’activer dans votre version).

Si getProduct() est implémenté via un ORM, un pilote de base de données ou un SDK, instrumentez plutôt ce niveau. Pour une enquête rapide en local, même des données de chronométrage rudimentaires suffisent à révéler un schéma :

export async function getProduct(slug: string) {
  console.time(`product:${slug}`)
const product = await db.product.findUnique({
    where: { slug },
  })
  console.timeEnd(`product:${slug}`)
  return product
}

Si vous voyez cette étiquette affichée quatre fois par chargement de page, c’est une preuve. En environnement de production, vous avez besoin de signaux plus forts : journaux des requêtes de base de données, suivi distribué, spans APM, compteurs de requêtes en amont et identifiants de requête qui vous permettent de relier chaque requête à la vue de page qui l’a générée.

L’essentiel est de distinguer deux affirmations qui sonnent similaires :

Function called four times

contre :

Underlying data source hit four times

Ils ne sont pas équivalents. Si l’opération s’exécute déjà une seule fois, l’envelopper dans une autre couche n’est pas une optimisation ; cela rend simplement la couche de données plus difficile à comprendre. Optimisez les tâches répétées que vous avez effectivement observées, et non les appels de fonction répétés que vous avez simplement remarqués dans le code.

Pourquoi une fonction basée sur un ORM échappe à la déduplication

Supposons que le chargeur de produits soit aussi simple que possible :

export async function getProduct(slug: string) {
  return db.product.findUnique({
    where: { slug },
  })
}

Imaginez maintenant qu’elle soit appelée depuis la fonction de métadonnées, la page, les chemins d’ navigation et un composant de tarification au cours d’une seule requête. La fonction est la même, l’argument est le même et la requête est identique. Sans limite de mémorisation, chaque appel effectue néanmoins sa propre requête vers la base de données.

Pour les ORM ou l’accès direct à la base de données, React cache() fournit cette mémorisation par requête que le fetch natif obtient gratuitement dans l’arborescence React. Next.js décrit précisément ce schéma pour les requêtes directes à la base de données, y compris le cas où le même enregistrement est nécessaire tant par generateMetadata que par la page.

Cette compréhension permet également d’éviter un mythe répandu. Si getProduct() était entièrement basé sur des appels fetch identiques, l’envelopper dans cache() uniquement pour éliminer ces doublons ne servirait pas à grand-chose, car Next.js les mémorise déjà. Par conséquent, la règle n’est pas d’appliquer cache() à chaque chargeur côté serveur. Il s’agit plutôt de vérifier si la manière dont les données sont chargées est déjà mémorisée, et d’ajouter une identité partagée uniquement lorsque cela fait défaut. Cette version est beaucoup plus difficile à appliquer de manière aveugle.

La solution : une fonction de données mémorisée

La modification du code est elle-même mineure. Voici le chargeur avant :

export async function getProduct(slug: string) {
  return db.product.findUnique({
    where: { slug },
  })
}

Et voici ce qu’il devient après l’avoir enveloppé dans cache() :

import { cache } from 'react'
import 'server-only'
export const getProduct = cache(async (slug: string) => {
  return db.product.findUnique({
    where: { slug },
  })
})

Deux points méritent d’être notés. L’import server-only fait échouer la compilation si ce module est intégré dans du code client, ce qui constitue une protection judicieuse pour tout élément communiquant avec votre base de données. De plus, cache() enrobe la fonction une seule fois, au niveau du module, de sorte que chaque importateur reçoit la même fonction mémorisée. Chaque utilisateur continue de l’appeler exactement comme avant :

const product = await getProduct(slug)

React stocke le résultat de chaque argument dans son cache côté serveur. Toute appel ultérieur au cours de cette même requête, via ce même wrapper et avec un argument identique, récupère le résultat stocké, qui correspond en réalité à la même promesse ; ainsi, les appels concurrents attendent une seule requête. React élimine ces résultats mémorisés entre les requêtes serveur.

Ce qui n’a pas eu besoin de changer

La partie précieuse de cette correction réside dans tout ce qui est resté inchangé. L’en-tête du produit indique toujours ses propres exigences de données. Les chemins d’ navigation n’acquièrent aucune nouvelle propriété. La génération des métadonnées et l’affichage de la page continuent de demander les données du produit de manière indépendante, et aucun composant UI n’est contraint d’assumer une responsabilité qu’il ne possède pas naturellement. L’optimisation se situe entièrement au niveau des données. Ce qui a été centralisé, ce n’est pas l’endroit où les données sont consommées, mais l’identité de l’opération qui les génère.

Péripéties avec cache()

Quelques détails d’implémentation déterminent si la mémorisation fonctionne réellement :

  • Partager une fonction mémorisée. En enveloppant un chargeur avec cache() en deux endroits différents, on obtient deux fonctions mémorisées indépendantes, chacune ayant sa propre mémoire de stockage, un comportement que React décrit explicitement. Définissez l’enveloppe une seule fois dans votre module d’accès aux données et importez-la partout ailleurs.
  • Préférer des arguments primitifs. React compare les arguments par identité, de sorte qu’une chaîne de slug est toujours stockée dans le cache, tandis qu’un objet créé à chaque appel, comme { slug }, ne sera jamais mémorisé.
  • Se souvenir du contexte. cache() est conçu pour le rendu serveur ; en dehors d’une requête, par exemple dans un composant client, il ne permet pas cette déduplication.

Pourquoi ne pas simplement récupérer tout ce qui est nécessaire sur la page ?

L’alternative évidente consiste à charger le produit une seule fois en haut de la page et à le transmettre ensuite :

export default async function ProductPage({
  params,
}: PageProps<'/products/[slug]'>) {
  const { slug } = await params
  const product = await getProduct(slug)
return (
    <>
      <Breadcrumbs product={product} />
      <ProductHeader product={product} />
      <ProductDetails product={product} />
    </>
  )
}

Lorsque la page possède réellement l’objet produit dans son intégralité, il s’agit d’une conception tout à fait valide. Les problèmes commencent lorsque l’on transfère toutes les dépendances de données uniquement pour éviter des tâches redondantes côté backend. Avec le temps, les props se multiplient, les composants intermédiaires commencent à transmettre des données qu’ils n’utilisent jamais, et chaque nouveau composant enfant ayant besoin d’un champ force des modifications tout au long de la chaîne. Les limites entre composants finissent alors par refléter des mécanismes d’optimisation plutôt qu’une véritable appropriation des données.

La mémorisation des requêtes permet de trouver un équilibre. Les directives actuelles de Next.js indiquent que des appels fetch identiques peuvent rester dans les composants qui en ont besoin, sans avoir recours à un chargement au niveau supérieur ou à des recherches profondes dans les props. De plus, pour un accès direct à la base de données, cache() offre une fonction serveur partagée avec des semantics de déduplication similaires. Vous obtenez ainsi les deux avantages en même temps :

data close to consumer
        +
deduplicated underlying work

Éliminer les tâches redondantes ne devrait pas forcer des composants non liés à partager la propriété d’un même objet de données. Le déplacement vers le niveau parent reste une bonne option lorsque ce dernier possède naturellement les données ; il ne devrait toutefois pas être obligatoire, car la couche de données manque d’identité pour les tâches répétées.

La mémorisation des requêtes n’est pas un cache persistant

La terminologie de Next.js peut facilement prêter à confusion, il est donc utile d’être précis. La fonction cache() de React dans ce schéma ne transforme pas la recherche d’un produit par un visiteur en une réponse stockée pour les visiteurs ultérieurs. React efface ses résultats serveur mémorisés à chaque requête. Au sein d’une même requête, les appels répétés réutilisent le résultat :

getProduct("keyboard")
getProduct("keyboard")
getProduct("keyboard")
//reuse the memoized result

Une requête ultérieure démarre avec un cache vide et effectue à nouveau la recherche :

New request
getProduct("keyboard")
//perform the lookup again

C’est là la mémorisation des requêtes, et rien de plus. La réutilisation des résultats entre différentes requêtes relève d’une décision architecturale distincte. Dans le Next.js actuel, les composants Cache fournissent la directive use cache pour stocker des résultats au-delà d’une seule requête, cacheLife() permettant de contrôler la durée de vie d’une entrée et cacheTag() assurant l’invalidation ciblée. Le guide sur use cache et la révalidation basée sur des étiquettes aborde ce sujet en détail.

  • Mémorisation des requêtes : faut-il exécuter quatre fois la même opération pendant que l’on construit une réponse ?
  • Cachage persistant : une requête ultérieure peut-elle réutiliser une réponse déjà calculée ?

Seul le deuxième élément introduit la notion de fraîcheur et d’invalidation. En les considérant comme deux décisions distinctes, la logique de mise en cache de Next.js devient bien moins confuse.

D’où proviennent réellement les économies

La requête vers le produit peut elle-même être parfaitement normale. Supposons que les données de télémétrie indiquent quatre opérations de base de données identiques par requête, et qu’après le changement il n’en reste plus qu’une. Vous avez ainsi économisé trois recherches par affichage de page, ce qui semble anodin. Multipliez cela par le volume du trafic : une route recevant des milliers d’affichages évite trois fois autant de requêtes, et une route très fréquentée sur une journée en évite encore beaucoup plus. Une légère duplication sur une route très sollicitée peut entraîner des milliers de requêtes inutiles à la base de données ou d’appels en amont, sans que aucune opération individuelle ne paraisse anormale.

C’est aussi pour cette raison que des chiffres concrets d’économies ne doivent figurer dans une affirmation que s’ils proviennent de vos propres mesures. Si vos indicateurs de production indiquent un nombre précis d’opérations évitées, citez ce chiffre ; sans télémesure, l’expression « peut économiser des milliers » décrit honnêtement l’effet multiplicateur sans inventer un cas d’étude.

Cela change également la manière dont vous abordez le travail d’amélioration des performances. La question habituelle est :

Which query takes 800 ms?

Fréquemment, la question plus pertinente est :

Why are we paying for this normal query
four times within one request?

Une recherche peut être peu coûteuse individuellement mais gaspiller des ressources collectivement. Le travail d’amélioration des performances ne consiste pas seulement à rendre chaque opération plus rapide ; parfois, il s’agit d’éliminer des opérations qui n’avaient jamais besoin d’exister, et cela est particulièrement important lorsque de petites inefficacités se trouvent sur des chemins fréquemment empruntés.

Choisir la bonne limite de réutilisation

La demande de mémorisation est relativement facile à comprendre, car React ne transmet jamais un résultat mémorisé à une demande future. Le stockage en cache persistant nécessite une discussion plus approfondie sur la correction des résultats. Avec les composants de cache, le travail mémorisé peut disposer de durées de vie et d’étiquettes explicites pour une révalidation précise, car sa réutilisation entre demandes soulève des questions quant à la durée pendant laquelle une valeur reste valide et aux événements qui devraient la rendre obsolète.

Ainsi, la meilleure question n’est pas :

Can we cache this?

mais plutôt :

Across which boundary is reuse correct?

Une façon approximative de comprendre les limites possibles :

  • Dans le cadre d’une seule demande : c’est sûr pour presque toute lecture, y compris les données par utilisateur, puisqu’aucun élément ne survit à la réponse. C’est là que cache() fonctionne.
  • Entre demandes, pour les données publiques : cela convient aux contenus identiques pour tous les visiteurs, à condition de définir une durée de vie et un mécanisme d’invalidation.
  • Pour les données spécifiques à un utilisateur, d’une requête à l’autre : la clé de cache doit inclure l’identité de l’utilisateur, sinon un utilisateur peut recevoir les résultats d’un autre utilisateur.
  • Pour les données dépendantes de l’autorisation, d’une requête à l’autre : ce qui détermine l’accès doit faire partie de l’identité de réutilisation, sinon les vérifications de permissions sont contournées silencieusement.
  • Ces deux derniers points ne sont pas spécifiques à Next.js ; ils s’appliquent à tout cache. Dès que la sortie varie en fonction de celui qui demande ou de ce qu’il peut voir, la clé qui décide de la réutilisation doit encoder cette même distinction. Un taux d’hits élevé ne signifie rien si une réponse peut être transmise à une requête qui n’en avait pas le droit. Le meilleur cache est celui dont les règles de réutilisation correspondent aux règles de correction des données.

    Les requêtes représentaient un problème lié aux limites

    Revenons à l’appel qui a tout déclenché :

    await getProduct(slug)
    

    Rien n’était incorrect dans generateMetadata(), ni sur la page, ni dans un composant serveur imbriqué. Chaque consommateur avait réellement besoin du produit. Le gaspillage apparaissait uniquement parce que ces appels logiques traversaient chacun séparément la même frontière backend. La solution ne nécessitait pas d’accélérer les requêtes ; il suffisait de se rendre compte qu’une même réponse était achetée plusieurs fois à chaque rendu. En code, toute la correction peut se résumer à un simple enveloppeur, défini une seule fois dans le module de données et partagé par tous les consommateurs :

    cache(async (...) => ...)
    

    Points clés

    • Les appels natifs identiques à fetch sont déjà mémorisés dans l’arbre React par Next.js. Les appels ORM, de pilote ou SDK ne le sont pas, et cache() leur confère une identité mémorisée par requête.
    • Mesurez d’abord. Une fonction appelée quatre fois n’est pas équivalente à une source de données consultée quatre fois.
  • Partager une seule fonction mémorisée ; des enveloppes cache() distinctes ne partagent pas les résultats.
  • Il n’est pas nécessaire de sacrifier de bonnes frontières entre composants. Éliminez les doublons là où cela est utile, et faites du stockage en mémoire persistante un choix délibéré, avec ses propres règles de fraîcheur et de sécurité.
  • Leçon plus générale : de nombreux améliorations significatives en termes de performance dans Next.js proviennent moins du lieu où les données sont chargées que de la décision quant à la durée pendant laquelle un accès aux données doit être considéré comme une seule opération.