“Il y a seulement 2 problèmes compliqués en informatique : nommer les choses, et l’invalidation de cache”. Phil Karlton.
Avec Next.js 13.4, l'App Router passe en version stable, 6 mois après sa sortie en beta dans Next.js 13. Dans ce nouveau système de routeur, le framework introduit de nombreux changements, dont les React Server Components, auxquels l'écosystème React commence tout juste à s'adapter. Pour maximiser leur performance, le framework a sorti l'artillerie lourde en matière de cache, avec pas moins 4 couches différentes. Tout ce qu'il faut pour se tirer une balle dans le pied quand on ne connait pas bien leur fonctionnement.
En complément, dans cet article je vous propose de découvrir ces différents types de cache à travers un exemple concret, et d'apprendre comment les désactiver au besoin.
Dans notre scénario, on nous demande de développer un composant qui affiche une carte membre associative d'un personnage fictif aléatoire. À chaque chargement de page, le personnage affiché doit être différent. En plus de son nom et prénom, on nous fait la demande un peu farfelue d'afficher son second prénom (middle-name en anglais). On veut également afficher l'année depuis laquelle le personnage est membre du site, son email et son téléphone. Voici à quoi ressemblerait la carte membre :
Comme le composant n'est pas interactif, on décide d'en faire un Server Component. Pour obtenir des informations aléatoires et plausibles sur le personnage, on choisit d'utiliser l'API
randomuser.me.
On obtient donc une première version du code que voici :
import { MemberCard } from "@/components/MemberCard"; const getRandomUser = async () => { const res = await fetch( "https://randomuser.me/api/?nat=fr&gender=female" ); const { results } = await res.json(); return results[0]; }; export default async function StaticPage() { const subscriptionYear = 1999 + Math.round(Math.random() * 25); const user1 = await getRandomUser(); const user2 = await getRandomUser(); return ( <div className="p-6"> <MemberCard firstName={user1.name.first} middleName={user2.name.first} lastName={user1.name.last} email={user1.email} phoneNumber={user1.phone} profilePictureUrl={user1.picture.large} subscriptionYear={subscriptionYear} /> </div> ); }
Certes, appeler l'API 2 fois n'est pas le plus efficace, et on pourrait utiliser Promise.all, mais nous allons garder ce code pour illustrer nos problèmes de cache. Nous avons dû générer l'année de d'inscription avec Math.random() car l'API ne dispose pas de cette information.
On tombe tout de suite sur la première couche de cache. En dev sur notre machine, seule l'année d'inscription change quand on recharge la page, et pire encore, quand on est en production, rien ne change quand on recharge.
Voici une première chose qu'il faut bien garder en tête : le comportement du cache n'est pas du tout le même en mode dev qu'en mode production. Le développeur peut donc facilement se faire piéger s'il ne teste son code qu'en mode dev et se rendre compte d'un problème seulement en staging ou en production. Pour tester des potentiels problèmes de cache en local il faut lancer le projet avec la commande NODE_ENV="production" npm run build && NODE_ENV="production" npm run start.
Ce premier système de cache s'appelle le
Full Route Cache.
Par défaut, notre page est statique, c'est à dire que Next.js ne procède au rendu de notre composant/page qu'une seule et unique fois : au moment du build. Le résultat est stocké dans un fichier statique qui est servi quand l'utilisateur accède à la page. On ne fait appel à l'API qu'au moment du build également. C'est donc très efficace, la page est servie rapidement et sans opération coûteuse, mais ce n'est pas du tout ce que l'on veut dans notre cas.
Pour désactiver ce comportement et
rendre notre page dynamique, il y a plusieurs solutions (
voir la doc). Next.js essaye de deviner tout seul quand notre page est dynamique. Par exemple, dès que nous accédons à un searchParameter, aux cookies ou aux headers, le framework comprend que la page doit être dynamique et désactive le Full Route Cache.
Dans notre cas, on va imaginer que l'on veuille pouvoir traduire notre carte dans différentes langues. On va donc lire le paramètre 'lang' dans l'url de notre page. (ex : https://notresite.com/membre?lang=en). Cela a pour effet de désactiver le cache et rendre notre route dynamique.
On a donc le code suivant :
export default async function DynamicPage(props: { searchParams: { lang?: string } }) { const lang = props.searchParams["lang"] ?? 'en'; return <MemberCard lang={lang} ... /> }
Next.js détecte tout seul lorsqu'on accède à une propriété de searchParams (probablement en utilisant un
Proxy), et désactive notre cache.
Malheureusement notre générateur ne fonctionne toujours pas correctement. Cette fois en production, l'année d'inscription change à chaque rechargement, preuve que le Full Route Cache est bien désactivé et que Next.js procède au rendu de notre composant, mais les informations du personnage sont toujours les mêmes.
Ceci provient d'une autre forme de cache : le
Data Cache. Next.js a en fait modifié la fonction 'fetch' côté serveur pour intercepter les requêtes et les mettre en cache, en utilisant l'url comme clé. Ce cache est activé par défaut et persiste d'une requête à l'autre et
même lorsqu'on rebuild et redeploy notre projet ! Ce cache peut être très puissant mais dans notre cas il est gênant, on aimerait donc bien le désactiver.
Pour cela, encore une fois, il y a plusieurs solutions, documentées
ici. Si l'on veut désactiver le cache seulement sur certaines requêtes on peut utiliser les paramètres
cache: 'no-store' ou
next.revalidate: 0 de fetch. (Bon à savoir : si l'on désactive le cache sur une seule des requêtes de la page, celle-ci deviendra dynamique automatiquement.) On peut également désactiver le Data Cache sur une route entière avec
export const dynamic="force-dynamic", ou
export const revalidate = 0.
Enfin, on peut manuellement invalider ce cache en utilisant l'option
next.tags de fetch et revalidateTag. Si par exemple vous utilisez Contentful pour rédiger vos articles et que vous les affichez sur votre blog Next.js, vous pouvez utiliser le système de
Webhook de Contentful en combinaison avec revalidateTag pour mettre à jour la page Next.js de votre article dès que vous le modifiez sur Contentful.
Pour revenir à notre cas, pour désactiver le Data Cache, on utilisera le code suivant :
const getRandomUser = async () => { const res = await fetch( "https://randomuser.me/api/?nat=fr&gender=male&test=3", { cache: "no-store", } ); const { results } = await res.json(); return results[0]; };
On teste notre code en production et... oui cette fois on a bien des données différentes à chaque chargement ! Sauf qu'étrangement on a deux fois le même prénom dans notre carte membre... on fetch pourtant bien deux utilisateurs différents dans notre code, on devrait avoir deux prénoms différents...
Et oui, il y a encore un type de cache à l'oeuvre: c'est la
Request Memoization. Cette fois il s'agit d'une fonctionnalité des React Components et non pas de Next.js. En plus du Data Cache de Next.js, React met en place un cache qui dure uniquement le temps d'une requête et qui ne s'applique que si une requête est effectuée dans un Server Component. La première fois qu'on fait appel à getRandomUser, on appel l'API randomuser.me avec fetch et React met en cache le résultat, puis la seconde fois React renvoie le résultat de la première requête, évitant une requête souvent inutile. Dans notre cas ça pose problème et encore une fois la documentation nous donne
une solution pour désactiver ce cache. Voici donc notre nouveau code :
const { signal } = new AbortController(); const getRandomUser = async () => { const res = await fetch( "https://randomuser.me/api/?nat=fr&gender=female&test=2", { signal, cache: "no-store", } ); const results = await res.json(); return results.results[0]; };
Bon cette fois c'est bon, à chaque fois qu'on recharge notre page, on a bien un utilisateur différent avec les bonnes données ! Mais on remarque un petit détail étrange : quand on va sur une autre page de notre site et qu'on revient sur notre page membre aléatoire, c'est le même utilisateur que lorsqu'on a quitté cette page. Qu'est-ce qui se passe ?
Et bien quand on change de page proprement en utilisant le composant Link ou la fonction navigate de Next.js, on ne change pas vraiment de page. Le framework va juste charger le Server Component qui correspond à la nouvelle page et procéder à son rendu, il met donc simplement à jour une partie du DOM : c'est ce qu'on appelle la
soft-navigation.
Or quand Next.js charge le Server Component de la page, il le met en cache et réutilise ce Server Component quand on revient sur la page : c'est le
Router Cache.
Il n'y a pas vraiment de paramètre pour désactiver ce type de cache. Cependant on peut manuellement l'invalider avec
useRouter().refresh() (
attention : useRouter de 'next/navigation', pas 'next/router') et bientôt avec les
server actions.
Par exemple si l'on veut rajouter un bouton pour générer un nouvel utilisateur, on pourrait utiliser le client component suivant :
"use client"; import { useRouter } from "next/navigation"; export const RefreshButton = () => { const router = useRouter(); return ( <button className="bg-sky-500 rounded-md text-white active:bg-sky-700 px-3 py-2" onClick={() => router.refresh()} > REFRESH </button> ); };
Et avec cela nous avons couvert les 4 formes de cache majeures de l'App Router.
Route handler caching
Je veux maintenant aborder juste un dernier point : le cache avec les Route Handlers. Il y a un système de cache qui s'applique uniquement aux requêtes GET, et qui fonctionne de façon assez différente de ce que nous avons vu au-dessus.
Il y a plusieurs points à noter :
Par défaut, les requêtes GET sont statiques ! Elles sont exécutées au build time uniquement et mises en cache pour toute la durée de l'application.
Par défaut, il y a un système de Data Cache s'applique uniquement au moment du build. Si vous faites un premier build, les données récupérées avec fetch seront mises en cache, et le build suivant ne mettra pas à jour ces données. Pour désactiver le Data Cache au build time, vous pouvez utiliser cache: "no-store".
Vous pouvez rendre votre requête dynamique en utilisant le paramètre Request de votre handler (req.headers.get('referer'), req.url, req.cookies, etc...) et cela rendra votre route automatiquement dynamique. Vous pouvez également utiliser l'option export const dynamic = "force-dynamic" ou export const revalidate = 0.
Attention l'option { cache: "no-store" } de fetch ne rendra pas votre route dynamique !
Attention, actuellement (Next.js 13.4.19), { next: { revalidate: 0 } } ne marche pas ! La valeur zéro semble être traitée comme un undefined au lieu de désactiver le cache. Probablement un bug. En revanche revalidate: 1 semble bien fonctionner.
Il n'y a pas de Data Cache au Runtime ! Il n'y pas non plus de Request Memoization. Si votre route est dynamique, tout les fetch feront une requête réseau.
Donc soit votre route est complètement statique, soit elle est complètement dynamique.
Conclusion
Next.js offre de nombreux différents types de cache différents - Full Route Cache, Data Cache, Request Memoization et Router Cache - qu'il est essentiel de bien comprendre. Il faut bien garder en tête que le cache ne se comporte pas de la même façon en mode development qu'en mode production. Le système de cache des routes handlers est également assez différent et surprenant dans son fonctionnement. Il est donc important de bien lire l'excellente documentation de Next.js et se familiariser avec ses nouvelles fonctionnalités.