TanStack Query v5 avec Next.js App Router : gérez le state serveur sans Redux
Pendant des années, Redux a été la solution par défaut pour gérer l'état dans les applications React. Mais si une grande partie de cet état correspond à des données venant d'un serveur — ce qui est souvent le cas — Redux devient rapidement un outil trop lourd pour le problème. TanStack Query (anciennement React Query) résout ce problème avec élégance, et sa version 5 apporte des améliorations majeures pour s'intégrer parfaitement avec Next.js App Router.
Dans cet article, on va voir comment configurer TanStack Query v5 dans un projet Next.js avec l'App Router, gérer les fetches côté client, tirer parti du prefetching côté serveur, et éviter les pièges classiques d'hydratation.
Pourquoi TanStack Query plutôt que useState + useEffect ?
La combinaison useState + useEffect pour fetcher des données est fonctionnelle mais fragile. Elle ne gère pas nativement : le cache, la revalidation en arrière-plan, les états de chargement et d'erreur, la déduplication des requêtes ou le refetch automatique au focus de fenêtre.
TanStack Query résout tout ça en offrant une couche de synchronisation entre le state serveur et le client. Contrairement à Redux ou Zustand qui gèrent l'état local, TanStack Query est spécialement conçu pour le state serveur : des données qui viennent d'une API, qui peuvent être périmées, et qu'il faut régulièrement resynchroniser.
Avec Next.js App Router et les Server Components, TanStack Query reste pertinent pour les Client Components qui ont besoin d'interactivité : pagination, filtres dynamiques, mutations avec retour immédiat en UI.
Installation et configuration dans Next.js App Router
Installation des packages
npm install @tanstack/react-query @tanstack/react-query-devtools
Pour TypeScript, les types sont inclus dans le package principal depuis la v5.
Créer le Provider côté client
Dans l'App Router, les Providers React doivent être dans un Client Component. On crée un fichier dédié :
// app/providers.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { useState } from 'react'
export function Providers({ children }: { children: React.ReactNode }) {
// useState garantit que chaque requête crée son propre QueryClient
// et évite de partager l'état entre plusieurs utilisateurs côté serveur
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute avant de considérer les données périmées
refetchOnWindowFocus: false, // désactivé pour les apps de type dashboard
},
},
})
)
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
On l'intègre ensuite dans le layout racine :
// app/layout.tsx
import { Providers } from './providers'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="fr">
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
Écrire et typer ses premiers useQuery
Définir une query typée avec TypeScript
La v5 de TanStack Query améliore l'inférence de types. On recommande de centraliser les définitions de queries dans des fichiers queries/ ou hooks/ :
// hooks/use-articles.ts
import { useQuery } from '@tanstack/react-query'
interface Article {
id: string
title: string
slug: string
publishedAt: string
tags: string[]
}
// La query key est un tableau — c'est la clé de cache
export const articlesQueryKey = ['articles'] as const
async function fetchArticles(): Promise<Article[]> {
const res = await fetch('/api/articles', {
headers: { 'Content-Type': 'application/json' },
})
if (!res.ok) {
throw new Error(`Erreur API: ${res.status}`)
}
return res.json()
}
export function useArticles() {
return useQuery({
queryKey: articlesQueryKey,
queryFn: fetchArticles,
// staleTime local écrase le défaut global si besoin
staleTime: 5 * 60 * 1000, // 5 minutes
})
}
Utiliser le hook dans un Client Component
// components/articles-list.tsx
'use client'
import { useArticles } from '@/hooks/use-articles'
export function ArticlesList() {
const { data: articles, isLoading, isError, error } = useArticles()
if (isLoading) {
return <div className="animate-pulse">Chargement des articles...</div>
}
if (isError) {
return (
<div className="text-red-500">
Erreur : {error instanceof Error ? error.message : 'Inconnue'}
</div>
)
}
return (
<ul className="grid gap-4">
{articles?.map((article) => (
<li key={article.id} className="rounded-lg border p-4">
<h2 className="font-semibold">{article.title}</h2>
<div className="mt-1 flex gap-2">
{article.tags.map((tag) => (
<span key={tag} className="text-sm text-muted-foreground">
#{tag}
</span>
))}
</div>
</li>
))}
</ul>
)
}
Mutations avec useMutation : créer, mettre à jour, supprimer
TanStack Query v5 introduit une syntaxe unifiée pour useMutation, avec des callbacks onSuccess, onError et onSettled pour gérer les effets de bord.
// hooks/use-create-article.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { articlesQueryKey } from './use-articles'
interface CreateArticlePayload {
title: string
slug: string
tags: string[]
}
async function createArticle(payload: CreateArticlePayload) {
const res = await fetch('/api/articles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) throw new Error('Échec de la création')
return res.json()
}
export function useCreateArticle() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createArticle,
onSuccess: () => {
// Invalide le cache de la liste des articles après création
// TanStack Query va automatiquement refetcher
queryClient.invalidateQueries({ queryKey: articlesQueryKey })
},
onError: (error) => {
console.error('Erreur lors de la création :', error)
},
})
}
Le pattern invalidateQueries est la manière recommandée de resynchroniser le cache après une mutation. Il déclenche un refetch de toutes les queries dont la clé commence par articlesQueryKey.
Prefetching côté serveur avec l'App Router
L'un des apports majeurs de TanStack Query v5 est le support amélioré du prefetching côté serveur avec dehydrate / HydrationBoundary. Cela permet de précharger les données dans un Server Component, de les sérialiser et de les hydrater côté client — sans waterfall réseau.
// app/articles/page.tsx (Server Component)
import {
HydrationBoundary,
QueryClient,
dehydrate,
} from '@tanstack/react-query'
import { ArticlesList } from '@/components/articles-list'
async function fetchArticlesServer() {
// Appel direct à l'API ou à la base de données — pas de fetch relatif ici
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/articles`, {
cache: 'no-store', // ou 'force-cache' selon la stratégie
})
return res.json()
}
export default async function ArticlesPage() {
const queryClient = new QueryClient()
// Précharge les données dans le cache côté serveur
await queryClient.prefetchQuery({
queryKey: ['articles'],
queryFn: fetchArticlesServer,
})
return (
// HydrationBoundary transmet le cache hydraté au Client Component
<HydrationBoundary state={dehydrate(queryClient)}>
<ArticlesList />
</HydrationBoundary>
)
}
Avec ce pattern, la page s'affiche instantanément côté client car les données sont déjà dans le cache — plus d'état de chargement visible pour l'utilisateur au premier rendu.
Bonnes pratiques et pièges à éviter
Structurer les query keys de manière hiérarchique
Les query keys en tableau permettent d'invalider des groupes de queries :
// Toutes les queries liées aux articles
['articles']
// Un article spécifique par ID
['articles', articleId]
// Les commentaires d'un article spécifique
['articles', articleId, 'comments']
// Invalider tout ce qui concerne les articles d'un coup :
queryClient.invalidateQueries({ queryKey: ['articles'] })
Ne pas créer un nouveau QueryClient à chaque rendu
L'erreur classique est de créer le QueryClient directement dans le corps du composant sans useState. Cela recrée un nouveau client à chaque rendu et vide le cache systématiquement.
// ❌ FAUX — recrée le client à chaque rendu
function Providers({ children }) {
const queryClient = new QueryClient() // Problème !
// ...
}
// ✅ CORRECT — stable entre les rendus
function Providers({ children }) {
const [queryClient] = useState(() => new QueryClient())
// ...
}
Gérer l'erreur globalement avec un Error Boundary
Pour éviter de répéter la gestion d'erreur dans chaque composant, on peut combiner TanStack Query avec un ErrorBoundary React. La prop throwOnError: true dans les options de query permet de déléguer l'erreur au boundary le plus proche.
En résumé
TanStack Query v5 est devenu indispensable dans les projets Next.js App Router pour tout ce qui concerne le state serveur côté client :
- Il remplace avantageusement
useState+useEffectpour les fetches asynchrones - La configuration du Provider est simple mais doit respecter la règle du
useStatepour leQueryClient - Les
useMutationcombinés àinvalidateQueriespermettent de maintenir le cache synchronisé - Le prefetching serveur avec
HydrationBoundaryélimine les états de chargement visibles - Des query keys bien structurées rendent l'invalidation du cache précise et prévisible
La combinaison Server Components pour les données statiques + TanStack Query pour les données interactives est aujourd'hui l'architecture la plus solide pour une application Next.js en production.
Tu travailles sur un projet Next.js et tu te demandes comment architecturer la gestion de données entre Server Components et Client Components ? Contacte-moi, je serai ravi d'en discuter.