SD
Next.jsTanStack QueryReact QueryTypeScriptState Management

TanStack Query v5 avec Next.js App Router : gérez le state serveur sans Redux

Découvrez comment intégrer TanStack Query v5 dans Next.js App Router pour gérer le state serveur efficacement, avec cache, invalidation et prefetching.

AMAlexis Mouchon9 min de lecture

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 :

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.