Pourquoi TanStack élimine votre boilerplate React ?
Dans toute application React, les donnees proviennent de deux sources distinctes : le client state (theme, formulaires, UI locale) et le server state (utilisateurs, produits, commandes). Ces deux types d'etat obeissent a des regles radicalement differentes. Le server state est asynchrone, partage entre plusieurs clients, et peut devenir obsolete a tout moment sans que votre application le sache. Gerer ce server state avec useState et useEffect revient a reinventer la roue -- mal. C'est exactement le probleme que TanStack resout.
Le probleme du Server State
Le server state introduit des defis que le client state n'a pas : la mise en cache, la deduplication de requetes identiques, la revalidation en arriere-plan, la gestion des donnees obsoletes, et les mises a jour optimistes. Un simple useEffect avec fetch ne gere aucun de ces cas. Multiplier les useEffect dans une application de taille reelle conduit inevitablement a des race conditions, des waterfalls de requetes, et un code de plus en plus fragile.
L'ecosysteme TanStack
TanStack est une collection de librairies headless, framework-agnostic et type-safe, concues pour resoudre des problemes fondamentaux du developpement frontend. Chaque librairie peut etre utilisee independamment.
TanStack Query
Gestion du server state : fetching, caching, synchronisation et mise a jour des donnees asynchrones. La librairie phare de l'ecosysteme.
TanStack Router
Routeur 100% type-safe avec loaders, search params valides, et integration native avec TanStack Query.
TanStack Table
Moteur de tableaux headless : tri, filtrage, pagination, groupement, selection -- sans aucune UI imposee.
TanStack Virtual
Virtualisation de listes et grilles pour afficher des milliers d'elements sans impacter les performances.
TanStack Form
Gestion de formulaires headless avec validation, arrays imbriques et performances optimisees par defaut.
TanStack Store & Pacer
Store : gestion d'etat reactif framework-agnostic. Pacer : utilitaires de rate limiting, debounce et throttle.
- Aucune dependance externe
- Controle total sur le flux
- Adapte pour un fetch ponctuel tres simple
- Aucun cache : refetch a chaque montage
- Race conditions non gerees
- Pas de deduplication des requetes identiques
- Code boilerplate repetitif
- Aucune revalidation en arriere-plan
- •Prototypes rapides
- •Composants isoles sans reutilisation
- •Projets sans server state significatif
- Cache intelligent avec staleTime/gcTime
- Revalidation automatique (focus, reconnect)
- Deduplication des requetes
- Mutations optimistes
- DevTools puissants
- TypeScript first-class
- Courbe d'apprentissage initiale
- Dependance externe (~13kB gzip)
- Overhead pour des cas tres simples
- •Applications avec CRUD complexe
- •Dashboards temps reel
- •Applications multi-pages avec donnees partagees
- •Tout projet avec server state significatif
- API minimale et intuitive
- Bundle plus leger (~4kB gzip)
- Revalidation automatique
- Integration Next.js fluide
- Pas de mutations structurees
- Pas d'invalidation granulaire
- Pas d'infinite queries natif
- DevTools limites
- Moins de controle sur le cache
- •Applications Next.js simples
- •Projets privilegiant la legerete
- •Fetching read-only predominant
useEffect brut | TanStack Query | SWR |
|---|---|---|
Approche manuelle avec useState + useEffect + fetch. Aucune couche d'abstraction : chaque composant gere son propre loading, error et cache. | Solution complete de gestion du server state avec cache normalise, revalidation automatique, mutations avec invalidation, et devtools integres. | Librairie de fetching par Vercel, axee sur la strategie stale-while-revalidate. Plus legere que TanStack Query, mais moins de fonctionnalites avancees. |
Avantages
| Avantages
| Avantages
|
Inconvenients
| Inconvenients
| Inconvenients
|
Cas d'usage
| Cas d'usage
| Cas d'usage
|
Philosophie headless et framework-agnostic
Toutes les librairies TanStack partagent une meme philosophie de conception qui les distingue des solutions classiques.
Headless
TanStack fournit la logique, pas l'interface. Vous gardez un controle total sur le rendu. Contrairement a AG Grid ou Material Table, il n'y a aucun style impose. Cela signifie une integration parfaite avec votre design system existant, qu'il soit base sur Tailwind, CSS Modules ou styled-components.
Framework-agnostic
Le coeur de chaque librairie est ecrit en TypeScript pur, sans dependance a React. Des adaptateurs existent pour React, Vue, Solid, Svelte et Angular. Vos connaissances sont transferables d'un framework a l'autre.
Type-safe first
Chaque API est concue avec TypeScript des le depart. Les query keys sont types, les mutations infèrent le type du payload, et TanStack Router offre une auto-completion complete sur les routes, params et search params. L'experience developpeur TypeScript est de premier ordre.
Adopte dans les plus grandes applications
TanStack Query est utilise en production par Google, PayPal, Walmart, Microsoft, Amazon et des milliers d'autres entreprises. La librairie cumule plus de 44 000 etoiles sur GitHub et 5 millions de telechargements npm hebdomadaires. TanStack Table propulse les tableaux de donnees de Bloomberg, Uber et Netflix. Cette adoption massive garantit une maintenance active, une communaute solide, et une stabilite eprouvee sur des cas d'usage reels a grande echelle.
Comment gérer vos requêtes API sans useState ?
TanStack Query transforme la facon dont vous gerez les donnees asynchrones dans React. Au lieu de jongler avec useState, useEffect et des variables loading/error manuelles, vous declarezce que vous voulez fetcher et TanStack Query s'occupe du reste : cache, revalidation, deduplication, retry automatique et synchronisation entre composants.
Installation et configuration initiale
L'installation necessite un seul package principal. Le QueryClient est l'instance centrale qui gere le cache de toutes vos queries. Le QueryClientProvider doit envelopper votre application pour rendre le client accessible a tous les composants via le contexte React.
1// Installation2// npm install @tanstack/react-query3// npm install @tanstack/react-query-devtools (optionnel, recommande)4
5// app/providers.tsx6'use client';7
8import { QueryClient, QueryClientProvider } from '@tanstack/react-query';9import { ReactQueryDevtools } from '@tanstack/react-query-devtools';10import { useState } from 'react';11
12export function Providers({ children }: { children: React.ReactNode }) {13 // Creer le QueryClient dans un useState pour eviter14 // le partage entre requetes serveur en SSR15 const [queryClient] = useState(16 () =>17 new QueryClient({18 defaultOptions: {19 queries: {20 // Les donnees sont considerees fraiches pendant 60 secondes21 staleTime: 60 * 1000,22 // Le cache est conserve 5 minutes apres la derniere utilisation23 gcTime: 5 * 60 * 1000,24 // Retry 3 fois avec backoff exponentiel par defaut25 retry: 3,26 // Refetch quand la fenetre reprend le focus27 refetchOnWindowFocus: true,28 },29 },30 })31 );32
33 return (34 <QueryClientProvider client={queryClient}>35 {children}36 <ReactQueryDevtools initialIsOpen={false} />37 </QueryClientProvider>38 );39}40
41// app/layout.tsx42import { Providers } from './providers';43
44export default function RootLayout({ children }: { children: React.ReactNode }) {45 return (46 <html lang="fr">47 <body>48 <Providers>{children}</Providers>49 </body>50 </html>51 );52}useQuery : le hook fondamental
useQuery prend deux parametres essentiels : une query key qui identifie de facon unique les donnees dans le cache, et une query function qui effectue le fetch. En retour, il expose un objet riche avec l'etat complet de la requete.
1import { useQuery } from '@tanstack/react-query';2
3// Type pour nos donnees4interface User {5 id: number;6 name: string;7 email: string;8 role: 'admin' | 'user' | 'editor';9 createdAt: string;10}11
12// Fonction de fetch separee (bonne pratique)13async function fetchUsers(): Promise<User[]> {14 const response = await fetch('/api/users');15
16 if (!response.ok) {17 // TanStack Query attend une Error throwee pour declencher isError18 throw new Error(`Erreur HTTP ${response.status}: ${response.statusText}`);19 }20
21 return response.json();22}23
24// Composant utilisant useQuery25export function UserList() {26 const {27 data: users, // Les donnees (undefined tant que pas chargees)28 isLoading, // true au premier chargement (pas de donnees en cache)29 isFetching, // true a chaque fetch (y compris revalidation en background)30 isError, // true si la derniere requete a echoue31 error, // L'objet Error si isError === true32 status, // 'pending' | 'error' | 'success'33 isStale, // true si les donnees sont considerees obsoletes34 isPlaceholderData, // true si on affiche des donnees placeholder35 refetch, // Fonction pour forcer un refetch manuel36 } = useQuery({37 queryKey: ['users'],38 queryFn: fetchUsers,39 });40
41 // Etat de chargement initial42 if (isLoading) {43 return <UserListSkeleton />;44 }45
46 // Gestion d'erreur47 if (isError) {48 return (49 <div className="p-4 bg-red-50 border border-red-200 rounded-lg">50 <p className="text-red-800">51 Impossible de charger les utilisateurs : {error.message}52 </p>53 <button onClick={() => refetch()} className="mt-2 text-red-600 underline">54 Reessayer55 </button>56 </div>57 );58 }59
60 return (61 <div>62 {/* Indicateur de revalidation en arriere-plan */}63 {isFetching && (64 <div className="text-sm text-muted-foreground mb-2">65 Mise a jour en cours...66 </div>67 )}68
69 <ul className="space-y-2">70 {users?.map((user) => (71 <li key={user.id} className="p-3 border rounded-lg">72 <p className="font-medium">{user.name}</p>73 <p className="text-sm text-muted-foreground">{user.email}</p>74 <span className="text-xs px-2 py-1 rounded bg-primary/10 text-primary">75 {user.role}76 </span>77 </li>78 ))}79 </ul>80 </div>81 );82}Cycle de vie d'une query
Comprendre les differents etats par lesquels passe une query est essentiel pour gerer correctement l'affichage dans vos composants.
pending (isLoading)
Etat initial. Aucune donnee en cache. La query function est en cours d'execution pour la premiere fois. C'est le moment d'afficher un skeleton ou un spinner.
success (isSuccess)
Les donnees ont ete recues avec succes. Elles sont stockees en cache et accessibles via data. La query peut etre fresh (staleTime non ecoule) ou stale (prete a etre revalidee).
error (isError)
La query function a echoue apres les tentatives de retry. L'objet error contient les details. Les donnees precedemment en cache restent accessibles si elles existent.
isFetching (revalidation)
Un fetch est en cours, mais ce n'est pas forcement le premier. La revalidation en arriere-plan se produit quand la fenetre reprend le focus, ou quand staleTime est ecoule. Les donnees existantes restent affichees pendant ce temps.
Query Keys : la cle de voute du cache
Les query keys sont des tableaux serializables qui identifient de facon unique les donnees en cache. TanStack Query utilise un algorithme de serialisation deterministe : l'ordre des proprietes dans un objet n'a pas d'importance. Deux composants utilisant la meme query key partagent automatiquement les memes donnees en cache.
1// Query keys : du simple au complexe2
3// 1. String simple -- liste globale4useQuery({ queryKey: ['users'], queryFn: fetchUsers });5
6// 2. Avec un identifiant -- un element specifique7useQuery({ queryKey: ['users', userId], queryFn: () => fetchUser(userId) });8
9// 3. Avec un objet de filtres -- liste filtree10useQuery({11 queryKey: ['users', { role: 'admin', status: 'active' }],12 queryFn: () => fetchUsers({ role: 'admin', status: 'active' }),13});14
15// 4. Cle hierarchique -- sous-ressource16useQuery({17 queryKey: ['users', userId, 'posts'],18 queryFn: () => fetchUserPosts(userId),19});20
21// 5. Avec pagination22useQuery({23 queryKey: ['users', { page, limit, sortBy }],24 queryFn: () => fetchUsers({ page, limit, sortBy }),25});26
27// IMPORTANT : la serialisation est deterministe28// Ces deux cles sont IDENTIQUES en cache :29['users', { role: 'admin', status: 'active' }]30['users', { status: 'active', role: 'admin' }]31
32// Ces deux cles sont DIFFERENTES (l'ordre des elements du tableau compte) :33['users', 'active']34['active', 'users']35
36// Invalidation hierarchique :37// invalidateQueries({ queryKey: ['users'] }) invalide TOUTES ces queries :38// ['users']39// ['users', 1]40// ['users', { role: 'admin' }]41// ['users', 1, 'posts']Regles de serialisation des query keys
Les query keys suivent des regles precises de serialisation qui determinent si deux queries partagent ou non les memes donnees en cache.
- Les tableaux sont compares par reference positionnelle : ['users', 1] et [1, 'users'] sont deux cles differentes.
- Les objets sont serialises de facon deterministe : l'ordre des proprietes est ignore. { a: 1, b: 2 } egal { b: 2, a: 1 }.
- L'invalidation est hierarchique : invalider ['users'] invalide aussi ['users', 1] et ['users', 1, 'posts'].
- Seules les valeurs serializables sont acceptees : pas de fonctions, pas de classes, pas de symboles dans les query keys.
1// Fetch d'un utilisateur unique avec useQuery2import { useQuery } from '@tanstack/react-query';3
4interface UserProfile {5 id: number;6 name: string;7 email: string;8 avatar: string;9 bio: string;10 stats: {11 posts: number;12 followers: number;13 following: number;14 };15}16
17async function fetchUserProfile(userId: number): Promise<UserProfile> {18 const response = await fetch(`/api/users/${userId}`);19 if (!response.ok) {20 throw new Error('Utilisateur introuvable');21 }22 return response.json();23}24
25export function UserProfile({ userId }: { userId: number }) {26 const { data: profile, isLoading, isError, error } = useQuery({27 queryKey: ['users', userId],28 queryFn: () => fetchUserProfile(userId),29 // La query est desactivee si userId est invalide30 enabled: userId > 0,31 // Les donnees d'un profil restent fraiches 2 minutes32 staleTime: 2 * 60 * 1000,33 });34
35 if (isLoading) return <ProfileSkeleton />;36 if (isError) return <ErrorMessage message={error.message} />;37 if (!profile) return null;38
39 return (40 <div className="max-w-md mx-auto">41 <div className="flex items-center gap-4 mb-6">42 <img43 src={profile.avatar}44 alt={profile.name}45 className="w-16 h-16 rounded-full"46 />47 <div>48 <h2 className="text-xl font-bold">{profile.name}</h2>49 <p className="text-muted-foreground">{profile.email}</p>50 </div>51 </div>52
53 <p className="text-foreground/80 mb-4">{profile.bio}</p>54
55 <div className="grid grid-cols-3 gap-4 text-center">56 <div>57 <p className="text-2xl font-bold">{profile.stats.posts}</p>58 <p className="text-sm text-muted-foreground">Articles</p>59 </div>60 <div>61 <p className="text-2xl font-bold">{profile.stats.followers}</p>62 <p className="text-sm text-muted-foreground">Abonnes</p>63 </div>64 <div>65 <p className="text-2xl font-bold">{profile.stats.following}</p>66 <p className="text-sm text-muted-foreground">Abonnements</p>67 </div>68 </div>69 </div>70 );71}Quelles options pour optimiser votre cache ?
Maitriser les options avancees de useQuery permet de controler precisement le comportement du cache, d'optimiser les performances reseau, et de creer des experiences utilisateur fluides. Cette section couvre les parametres de cache, les requetes conditionnelles, les transformations de donnees, la pagination infinie et les requetes dependantes.
staleTime vs gcTime : comprendre le cache
Ces deux parametres sont les plus importants de TanStack Query et les plus mal compris. staleTime determine quand les donnees sont considerees obsoletes, tandis que gcTime determine quand elles sont supprimees du cache. Les deux travaillent ensemble pour offrir une experience utilisateur optimale.
- Reduit les requetes reseau inutiles
- Les donnees en cache sont servies instantanement
- Controle fin sur la fraicheur par type de donnees
- Ameliore la perception de performance
- Des donnees obsoletes peuvent etre affichees
- Necessite de choisir la bonne valeur par cas
- Trop eleve : donnees desynchronisees
- Trop bas : requetes excessives
- •Donnees statiques : staleTime: Infinity
- •Profils utilisateur : staleTime: 5min
- •Listes de produits : staleTime: 30s
- •Donnees temps reel : staleTime: 0
- Retour instantane sur les pages deja visitees
- Economise les requetes lors de navigation rapide
- Permet le pattern stale-while-revalidate
- Memoire liberee automatiquement
- Consomme de la memoire pour les donnees inactives
- Trop eleve : consommation memoire excessive
- Trop bas : perte du benefice cache
- Non applicable aux donnees sensibles
- •Navigation frequente : gcTime: 10min
- •Grandes listes : gcTime: 2min
- •Donnees sensibles : gcTime: 0
- •Application SPA : gcTime: 30min
staleTime | gcTime |
|---|---|
Duree pendant laquelle les donnees sont considerees fraiches. Tant que staleTime n'est pas ecoule, aucune revalidation automatique ne sera declenchee (ni sur window focus, ni sur montage). Defaut : 0 (les donnees deviennent stale immediatement). | Duree de retention du cache apres que TOUS les observateurs (composants) se sont desabonnes. Quand un composant est demonte, le timer gcTime demarre. Si aucun composant ne se reabonne avant l'expiration, les donnees sont supprimees. Defaut : 5 minutes. |
Avantages
| Avantages
|
Inconvenients
| Inconvenients
|
Cas d'usage
| Cas d'usage
|
Options de revalidation
TanStack Query offre plusieurs declencheurs de revalidation automatique. Chacun peut etre active ou desactive selon vos besoins. Ces options ne s'appliquent que lorsque les donnees sont considerees stale.
1import { useQuery } from '@tanstack/react-query';2
3// Configuration fine des options de revalidation4export function DashboardStats() {5 const { data } = useQuery({6 queryKey: ['dashboard', 'stats'],7 queryFn: fetchDashboardStats,8
9 // Refetch quand l'onglet reprend le focus (defaut: true)10 // Utile pour les donnees qui changent pendant que l'utilisateur11 // est sur un autre onglet12 refetchOnWindowFocus: true,13
14 // Refetch au montage du composant si les donnees sont stale (defaut: true)15 // Mettre false si le composant est monte/demonte frequemment16 refetchOnMount: true,17
18 // Refetch quand la connexion revient (defaut: true)19 // Essentiel pour les applications mobiles20 refetchOnReconnect: true,21
22 // Polling : refetch toutes les 30 secondes23 // Ideal pour les dashboards temps reel24 refetchInterval: 30_000,25
26 // Le polling continue meme quand l'onglet n'est pas visible27 // A utiliser avec parcimonie pour economiser les ressources28 refetchIntervalInBackground: false,29 });30
31 return <StatsGrid stats={data} />;32}33
34// Exemple : donnees statiques sans revalidation35export function AppConfig() {36 const { data: config } = useQuery({37 queryKey: ['config'],38 queryFn: fetchAppConfig,39 staleTime: Infinity, // Jamais stale40 refetchOnWindowFocus: false, // Pas de refetch au focus41 refetchOnMount: false, // Pas de refetch au montage42 refetchOnReconnect: false, // Pas de refetch a la reconnexion43 });44
45 return <ConfigDisplay config={config} />;46}Requetes conditionnelles avec enabled
L'option enabled permet de desactiver une query tant qu'une condition n'est pas remplie. La query ne sera pas executee et restera en etat pending. C'est essentiel pour les requetes dependantes ou les formulaires de recherche.
1import { useQuery } from '@tanstack/react-query';2import { useState } from 'react';3
4// Exemple 1 : Recherche declenchee par l'utilisateur5export function UserSearch() {6 const [searchTerm, setSearchTerm] = useState('');7 const [debouncedTerm, setDebouncedTerm] = useState('');8
9 const { data: results, isLoading, isFetching } = useQuery({10 queryKey: ['users', 'search', debouncedTerm],11 queryFn: () => searchUsers(debouncedTerm),12 // Ne lance pas la requete tant que le terme fait moins de 3 caracteres13 enabled: debouncedTerm.length >= 3,14 });15
16 return (17 <div>18 <input19 type="text"20 value={searchTerm}21 onChange={(e) => {22 setSearchTerm(e.target.value);23 // Debounce de 300ms avant de lancer la recherche24 clearTimeout(window.__searchTimeout);25 window.__searchTimeout = setTimeout(26 () => setDebouncedTerm(e.target.value),27 30028 );29 }}30 placeholder="Rechercher un utilisateur (min. 3 caracteres)..."31 />32
33 {isLoading && debouncedTerm.length >= 3 && <SearchSkeleton />}34 {isFetching && <span className="text-sm">Recherche en cours...</span>}35
36 {results?.map((user) => (37 <UserCard key={user.id} user={user} />38 ))}39 </div>40 );41}42
43// Exemple 2 : Requete dependante d'une autre44export function UserPosts({ userId }: { userId: number | null }) {45 // Cette query ne se lance que quand userId est non-null46 const { data: posts } = useQuery({47 queryKey: ['users', userId, 'posts'],48 queryFn: () => fetchUserPosts(userId!),49 enabled: userId !== null,50 });51
52 return <PostList posts={posts ?? []} />;53}Transformation de donnees avec select
L'option select permet de transformer les donnees retournees par la query function avant qu'elles soient exposees au composant. Le resultat du select est mis en cache separement, et la transformation n'est recalculee que lorsque les donnees brutes changent.
1import { useQuery } from '@tanstack/react-query';2
3interface ApiUser {4 id: number;5 first_name: string;6 last_name: string;7 email_address: string;8 is_active: boolean;9 created_at: string;10}11
12// La query function retourne les donnees brutes de l'API13async function fetchUsers(): Promise<ApiUser[]> {14 const res = await fetch('/api/users');15 return res.json();16}17
18// Composant 1 : n'a besoin que des noms19export function UserNameList() {20 const { data: names } = useQuery({21 queryKey: ['users'],22 queryFn: fetchUsers,23 // select transforme les donnees AVANT de les exposer24 // Si les donnees brutes n'ont pas change, select n'est pas recalcule25 select: (users) => users.map((u) => `${u.first_name} ${u.last_name}`),26 });27
28 return (29 <ul>30 {names?.map((name, i) => <li key={i}>{name}</li>)}31 </ul>32 );33}34
35// Composant 2 : ne veut que les utilisateurs actifs36export function ActiveUserCount() {37 const { data: count } = useQuery({38 queryKey: ['users'],39 queryFn: fetchUsers,40 select: (users) => users.filter((u) => u.is_active).length,41 });42
43 return <span>Utilisateurs actifs : {count ?? 0}</span>;44}45
46// Composant 3 : transformation avec tri et mapping47export function UserDirectory() {48 const { data: directory } = useQuery({49 queryKey: ['users'],50 queryFn: fetchUsers,51 select: (users) =>52 users53 .filter((u) => u.is_active)54 .sort((a, b) => a.last_name.localeCompare(b.last_name))55 .map((u) => ({56 id: u.id,57 fullName: `${u.first_name} ${u.last_name}`,58 email: u.email_address,59 })),60 });61
62 return <DirectoryTable entries={directory ?? []} />;63}64
65// Les 3 composants partagent le MEME cache pour ['users']66// mais chacun transforme les donnees differemment via selectplaceholderData vs initialData
Ces deux options servent a afficher des donnees avant que le fetch soit termine, mais elles ont des comportements tres differents au niveau du cache.
placeholderData
Donnees temporaires affichees pendant le premier chargement. Elles ne sont jamais ecrites dans le cache et disparaissent des que les vraies donnees arrivent. Le composant montre isPlaceholderData: true.
Cas : afficher les donnees de la page precedente pendant le chargement de la suivante (pagination smooth).
initialData
Donnees initiales qui sont ecrites dans le cache comme si elles provenaient du serveur. Elles sont traitees comme de vraies donnees. Utile quand vous disposez deja des donnees (par exemple depuis un loader SSR ou un cache parent).
Cas : hydrater le cache avec les donnees provenant d'un Server Component ou d'une page precedente.
1import { useQuery, keepPreviousData } from '@tanstack/react-query';2import { useState } from 'react';3
4// placeholderData : pagination fluide sans flash de loading5export function PaginatedUsers() {6 const [page, setPage] = useState(1);7
8 const { data, isPlaceholderData, isFetching } = useQuery({9 queryKey: ['users', { page }],10 queryFn: () => fetchUsers({ page, limit: 20 }),11 // keepPreviousData garde les donnees de la page precedente12 // pendant le chargement de la nouvelle page13 placeholderData: keepPreviousData,14 });15
16 return (17 <div>18 {/* Indicateur de chargement subtil sans supprimer le contenu */}19 <div className={isFetching ? 'opacity-60' : ''}>20 {data?.users.map((user) => (21 <UserRow key={user.id} user={user} />22 ))}23 </div>24
25 <div className="flex gap-2 mt-4">26 <button27 onClick={() => setPage((p) => Math.max(1, p - 1))}28 disabled={page === 1}29 >30 Precedent31 </button>32 <span>Page {page}</span>33 <button34 onClick={() => setPage((p) => p + 1)}35 // Desactiver si on affiche des donnees placeholder36 // (la page suivante n'existe peut-etre pas)37 disabled={isPlaceholderData || !data?.hasMore}38 >39 Suivant40 </button>41 </div>42 </div>43 );44}45
46// initialData : hydratation depuis des donnees deja disponibles47export function UserDetail({ userId, prefetchedUser }: {48 userId: number;49 prefetchedUser: User;50}) {51 const { data: user } = useQuery({52 queryKey: ['users', userId],53 queryFn: () => fetchUser(userId),54 // Les donnees sont ecrites dans le cache immediatement55 initialData: prefetchedUser,56 // Indiquer quand initialData a ete recupere pour le staleTime57 initialDataUpdatedAt: Date.now() - 60_000, // il y a 1 minute58 });59
60 return <UserProfile user={user} />;61}useInfiniteQuery : scroll infini et pagination
useInfiniteQuery gere automatiquement l'accumulation de pages de donnees. Il conserve toutes les pages chargees en cache et expose des fonctions pour charger les pages suivantes et precedentes.
1import { useInfiniteQuery } from '@tanstack/react-query';2import { useInView } from 'react-intersection-observer';3import { useEffect } from 'react';4
5interface PostsPage {6 posts: Post[];7 nextCursor: string | null;8 hasMore: boolean;9}10
11async function fetchPosts({ pageParam }: { pageParam: string | null }): Promise<PostsPage> {12 const url = pageParam13 ? `/api/posts?cursor=${pageParam}`14 : '/api/posts';15 const res = await fetch(url);16 return res.json();17}18
19export function InfinitePostFeed() {20 const { ref, inView } = useInView();21
22 const {23 data,24 fetchNextPage,25 hasNextPage,26 isFetchingNextPage,27 isLoading,28 isError,29 error,30 } = useInfiniteQuery({31 queryKey: ['posts', 'feed'],32 queryFn: fetchPosts,33 // Parametre initial pour la premiere page34 initialPageParam: null as string | null,35 // Extraire le curseur pour la page suivante36 getNextPageParam: (lastPage) =>37 lastPage.hasMore ? lastPage.nextCursor : undefined,38 });39
40 // Charger automatiquement la page suivante quand le sentinel est visible41 useEffect(() => {42 if (inView && hasNextPage && !isFetchingNextPage) {43 fetchNextPage();44 }45 }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);46
47 if (isLoading) return <FeedSkeleton />;48 if (isError) return <ErrorMessage message={error.message} />;49
50 return (51 <div className="space-y-4">52 {/* data.pages est un tableau de toutes les pages chargees */}53 {data?.pages.map((page, pageIndex) =>54 page.posts.map((post) => (55 <PostCard key={post.id} post={post} />56 ))57 )}58
59 {/* Sentinel element pour l'intersection observer */}60 <div ref={ref} className="h-10 flex items-center justify-center">61 {isFetchingNextPage && <LoadingSpinner />}62 {!hasNextPage && (63 <p className="text-muted-foreground text-sm">64 Tous les articles ont ete charges.65 </p>66 )}67 </div>68 </div>69 );70}Requetes paralleles et dependantes
Quand plusieurs queries sont independantes, elles s'executent en parallele automatiquement. Pour les requetes qui dependent du resultat d'une autre, l'option enabled permet de creer une chaine de dependances.
1import { useQuery, useQueries } from '@tanstack/react-query';2
3// Requetes paralleles automatiques4// Ces deux queries se lancent en meme temps5export function UserDashboard({ userId }: { userId: number }) {6 const profileQuery = useQuery({7 queryKey: ['users', userId],8 queryFn: () => fetchUser(userId),9 });10
11 const postsQuery = useQuery({12 queryKey: ['users', userId, 'posts'],13 queryFn: () => fetchUserPosts(userId),14 });15
16 const notificationsQuery = useQuery({17 queryKey: ['users', userId, 'notifications'],18 queryFn: () => fetchUserNotifications(userId),19 });20
21 // Les 3 queries s'executent en parallele22 const isLoading = profileQuery.isLoading23 || postsQuery.isLoading24 || notificationsQuery.isLoading;25
26 if (isLoading) return <DashboardSkeleton />;27
28 return (29 <div className="grid grid-cols-3 gap-6">30 <ProfileCard profile={profileQuery.data} />31 <PostsList posts={postsQuery.data} />32 <NotificationPanel notifications={notificationsQuery.data} />33 </div>34 );35}36
37// useQueries pour un nombre dynamique de queries paralleles38export function MultiUserComparison({ userIds }: { userIds: number[] }) {39 const userQueries = useQueries({40 queries: userIds.map((id) => ({41 queryKey: ['users', id],42 queryFn: () => fetchUser(id),43 staleTime: 5 * 60 * 1000,44 })),45 });46
47 const allLoaded = userQueries.every((q) => q.isSuccess);48 const users = userQueries.map((q) => q.data).filter(Boolean);49
50 return allLoaded ? <ComparisonGrid users={users} /> : <LoadingSkeleton />;51}52
53// Requetes dependantes : la seconde attend le resultat de la premiere54export function UserWithOrganization({ userId }: { userId: number }) {55 // 1. D'abord, charger l'utilisateur56 const { data: user } = useQuery({57 queryKey: ['users', userId],58 queryFn: () => fetchUser(userId),59 });60
61 // 2. Ensuite, charger l'organisation de l'utilisateur62 // Cette query ne se lance PAS tant que user est undefined63 const { data: organization } = useQuery({64 queryKey: ['organizations', user?.organizationId],65 queryFn: () => fetchOrganization(user!.organizationId),66 enabled: !!user?.organizationId,67 });68
69 return (70 <div>71 <UserCard user={user} />72 {organization && <OrganizationBadge org={organization} />}73 </div>74 );75}Comment synchroniser vos mutations avec le cache ?
Les mutations sont l'autre face de TanStack Query : si useQuery gere la lecture des donnees, useMutation gere l'ecriture. Creer, modifier, supprimer -- chaque operation qui change l'etat du serveur passe par une mutation. Le defi est ensuite de synchroniser le cache local avec les nouvelles donnees du serveur, soit par invalidation, soit par mise a jour directe.
useMutation : le hook d'ecriture
useMutation fournit une fonction mutate (ou mutateAsync) et un ensemble d'etats (isPending, isSuccess, isError) pour gerer le cycle de vie complet d'une operation d'ecriture. Les callbacks onSuccess, onError et onSettled permettent d'executer de la logique a chaque etape.
1import { useMutation, useQueryClient } from '@tanstack/react-query';2import { toast } from 'sonner';3
4interface CreateUserPayload {5 name: string;6 email: string;7 role: 'admin' | 'user' | 'editor';8}9
10interface User {11 id: number;12 name: string;13 email: string;14 role: string;15 createdAt: string;16}17
18async function createUser(payload: CreateUserPayload): Promise<User> {19 const response = await fetch('/api/users', {20 method: 'POST',21 headers: { 'Content-Type': 'application/json' },22 body: JSON.stringify(payload),23 });24
25 if (!response.ok) {26 const error = await response.json();27 throw new Error(error.message || 'Erreur lors de la creation');28 }29
30 return response.json();31}32
33export function CreateUserForm() {34 const queryClient = useQueryClient();35
36 const createUserMutation = useMutation({37 mutationFn: createUser,38
39 // Appele quand la mutation reussit40 onSuccess: (newUser) => {41 // Invalider la liste des utilisateurs pour forcer un refetch42 queryClient.invalidateQueries({ queryKey: ['users'] });43
44 // Notification de succes45 toast.success(`Utilisateur ${newUser.name} cree avec succes`);46
47 // Optionnel : pre-remplir le cache du detail utilisateur48 queryClient.setQueryData(['users', newUser.id], newUser);49 },50
51 // Appele quand la mutation echoue52 onError: (error) => {53 toast.error(`Echec : ${error.message}`);54 },55
56 // Appele dans tous les cas (succes ou echec)57 onSettled: () => {58 // Nettoyage, reset de formulaire, etc.59 console.log('Mutation terminee');60 },61 });62
63 const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {64 e.preventDefault();65 const formData = new FormData(e.currentTarget);66
67 createUserMutation.mutate({68 name: formData.get('name') as string,69 email: formData.get('email') as string,70 role: formData.get('role') as CreateUserPayload['role'],71 });72 };73
74 return (75 <form onSubmit={handleSubmit} className="space-y-4">76 <input name="name" placeholder="Nom" required />77 <input name="email" type="email" placeholder="Email" required />78 <select name="role">79 <option value="user">Utilisateur</option>80 <option value="editor">Editeur</option>81 <option value="admin">Administrateur</option>82 </select>83
84 <button85 type="submit"86 disabled={createUserMutation.isPending}87 className="px-4 py-2 bg-primary text-white rounded-lg disabled:opacity-50"88 >89 {createUserMutation.isPending ? 'Creation en cours...' : 'Creer'}90 </button>91
92 {createUserMutation.isError && (93 <p className="text-red-500 text-sm">94 {createUserMutation.error.message}95 </p>96 )}97 </form>98 );99}Invalidation : exact vs fuzzy matching
Apres une mutation, il faut synchroniser le cache. La methode la plus simple est l'invalidation : elle marque les queries comme stale et declenche un refetch automatique pour les queries actuellement observees. L'invalidation supporte deux modes : fuzzy (par defaut) qui invalide toutes les queries commencant par le prefixe, et exact qui ne cible qu'une query key precise.
1import { useQueryClient } from '@tanstack/react-query';2
3const queryClient = useQueryClient();4
5// ---- FUZZY MATCHING (defaut) ----6// Invalide TOUTES les queries dont la key commence par ['users']7// Correspond a : ['users'], ['users', 1], ['users', { page: 1 }], etc.8await queryClient.invalidateQueries({9 queryKey: ['users'],10});11
12// Invalide toutes les queries du user 4213// Correspond a : ['users', 42], ['users', 42, 'posts'], etc.14await queryClient.invalidateQueries({15 queryKey: ['users', 42],16});17
18// ---- EXACT MATCHING ----19// Invalide UNIQUEMENT la query ['users'] et rien d'autre20await queryClient.invalidateQueries({21 queryKey: ['users'],22 exact: true,23});24
25// ---- FILTRAGE PAR PREDICAT ----26// Invalider seulement les queries qui correspondent a une condition27await queryClient.invalidateQueries({28 predicate: (query) => {29 // Invalider toutes les queries stale qui commencent par 'users'30 return (31 query.queryKey[0] === 'users' &&32 query.state.isInvalidated === false33 );34 },35});36
37// ---- INVALIDATION AVEC TYPE ----38// Invalider seulement les queries actives (observees par un composant)39await queryClient.invalidateQueries({40 queryKey: ['users'],41 type: 'active', // 'active' | 'inactive' | 'all'42});43
44// ---- DANS UN CALLBACK onSuccess ----45const deleteUserMutation = useMutation({46 mutationFn: (userId: number) => deleteUser(userId),47 onSuccess: (_, deletedUserId) => {48 // Invalider la liste49 queryClient.invalidateQueries({ queryKey: ['users'] });50 // Supprimer du cache la query detail de l'utilisateur supprime51 queryClient.removeQueries({ queryKey: ['users', deletedUserId] });52 },53});Mise a jour directe du cache avec setQueryData
Pour une experience utilisateur encore plus reactive, vous pouvez mettre a jour le cache directement sans attendre la reponse du serveur. setQueryData ecrit des donnees dans le cache comme si elles provenaient d'un fetch. Cela est ideal quand le serveur retourne l'entite mise a jour dans sa reponse.
1import { useMutation, useQueryClient } from '@tanstack/react-query';2
3interface UpdateUserPayload {4 name?: string;5 email?: string;6 role?: string;7}8
9export function useUpdateUser() {10 const queryClient = useQueryClient();11
12 return useMutation({13 mutationFn: ({ userId, data }: { userId: number; data: UpdateUserPayload }) =>14 updateUserOnServer(userId, data),15
16 onSuccess: (updatedUser) => {17 // Mise a jour directe du cache du detail utilisateur18 // Pas de refetch necessaire : le serveur a retourne l'entite mise a jour19 queryClient.setQueryData(['users', updatedUser.id], updatedUser);20
21 // Mise a jour de la liste des utilisateurs en cache22 queryClient.setQueryData<User[]>(['users'], (oldUsers) => {23 if (!oldUsers) return [updatedUser];24 return oldUsers.map((user) =>25 user.id === updatedUser.id ? updatedUser : user26 );27 });28 },29 });30}31
32// Utilisation dans un composant33export function UserEditForm({ user }: { user: User }) {34 const updateUser = useUpdateUser();35
36 const handleSave = (data: UpdateUserPayload) => {37 updateUser.mutate(38 { userId: user.id, data },39 {40 // Callbacks specifiques a cet appel41 onSuccess: () => {42 toast.success('Modifications enregistrees');43 },44 }45 );46 };47
48 return (49 <form onSubmit={(e) => { e.preventDefault(); handleSave(formData); }}>50 {/* ... champs du formulaire ... */}51 <button disabled={updateUser.isPending}>52 {updateUser.isPending ? 'Enregistrement...' : 'Enregistrer'}53 </button>54 </form>55 );56}Mises a jour optimistes
Les mises a jour optimistes offrent la meilleure experience utilisateur : l'interface est mise a jour instantanement avant meme que le serveur ait repondu. Si la mutation echoue, un rollback automatique restaure l'etat precedent. Ce pattern requiert 4 etapes : annuler les queries en cours, sauvegarder un snapshot, appliquer la mise a jour optimiste, et prevoir le rollback.
1import { useMutation, useQueryClient } from '@tanstack/react-query';2
3interface Todo {4 id: number;5 title: string;6 completed: boolean;7}8
9export function useToggleTodo() {10 const queryClient = useQueryClient();11
12 return useMutation({13 mutationFn: (todoId: number) =>14 fetch(`/api/todos/${todoId}/toggle`, { method: 'PATCH' }).then((r) => r.json()),15
16 // ETAPE 1 : Avant que la mutation ne parte17 onMutate: async (todoId: number) => {18 // 1a. Annuler les queries en cours pour eviter les conflits19 // entre le refetch et notre mise a jour optimiste20 await queryClient.cancelQueries({ queryKey: ['todos'] });21
22 // 1b. Sauvegarder un snapshot de l'etat actuel pour le rollback23 const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);24
25 // 1c. Appliquer la mise a jour optimiste dans le cache26 queryClient.setQueryData<Todo[]>(['todos'], (old) =>27 old?.map((todo) =>28 todo.id === todoId29 ? { ...todo, completed: !todo.completed }30 : todo31 )32 );33
34 // 1d. Retourner le snapshot pour le rollback35 return { previousTodos };36 },37
38 // ETAPE 2 : En cas d'erreur, rollback39 onError: (_error, _todoId, context) => {40 // Restaurer le snapshot sauvegarde dans onMutate41 if (context?.previousTodos) {42 queryClient.setQueryData(['todos'], context.previousTodos);43 }44 toast.error('Erreur lors de la mise a jour. Modification annulee.');45 },46
47 // ETAPE 3 : Dans tous les cas, resynchroniser avec le serveur48 onSettled: () => {49 // Invalider pour s'assurer que le cache est en phase avec le serveur50 queryClient.invalidateQueries({ queryKey: ['todos'] });51 },52 });53}54
55// Utilisation : l'interface reagit instantanement56export function TodoItem({ todo }: { todo: Todo }) {57 const toggleTodo = useToggleTodo();58
59 return (60 <div className="flex items-center gap-3 p-3 rounded-lg border">61 <button62 onClick={() => toggleTodo.mutate(todo.id)}63 className={todo.completed ? 'line-through text-muted-foreground' : ''}64 >65 <span className={todo.completed ? 'bg-primary' : 'bg-muted'}>66 {todo.completed ? 'V' : ' '}67 </span>68 </button>69 <span>{todo.title}</span>70 </div>71 );72}Invalidation vs mise a jour directe : quand choisir quoi
Le choix entre invalidation et mise a jour directe du cache depend de plusieurs facteurs. Voici un guide pour prendre la bonne decision.
Privilegier l'invalidation quand :
- La mutation affecte des donnees que vous n'avez pas en local (tri cote serveur, aggregations)
- Plusieurs queries dependantes doivent etre rafraichies
- La structure du cache est complexe et la mise a jour manuelle serait fragile
- Le cout reseau d'un refetch est acceptable
Privilegier la mise a jour directe quand :
- Le serveur retourne l'entite complete mise a jour dans la reponse
- Vous voulez une reactivite instantanee sans requete supplementaire
- La transformation du cache est simple et previsible (remplacement d'un element)
- Vous implementez des mises a jour optimistes avec rollback
Conseil pratique : commencez toujours par l'invalidation. Elle est plus simple, plus sure et couvre 80% des cas. Migrez vers la mise a jour directe uniquement quand la performance ou l'experience utilisateur le justifient.
Comment structurer vos queries en production ?
A mesure qu'une application grandit, la gestion des queries devient un enjeu d'architecture. Repeter les query keys et les query functions dans chaque composant conduit a des incoherences, des erreurs de typage et un code difficile a maintenir. Cette section presente les patterns d'organisation qui transforment TanStack Query en une couche de donnees robuste et scalable.
queryOptions() : centraliser key et function
Le helper queryOptions() cree un objet reutilisable qui encapsule la query key, la query function et toutes les options. TypeScript infere automatiquement le type de retour. Cet objet peut etre passe directement a useQuery, prefetchQuery ou fetchQuery.
1import { queryOptions, useQuery, useQueryClient } from '@tanstack/react-query';2
3// ---- Definition centralisee ----4
5interface User {6 id: number;7 name: string;8 email: string;9 role: 'admin' | 'user' | 'editor';10}11
12// queryOptions cree un objet type-safe reutilisable partout13export const userQueryOptions = (userId: number) =>14 queryOptions({15 queryKey: ['users', userId],16 queryFn: async (): Promise<User> => {17 const res = await fetch(`/api/users/${userId}`);18 if (!res.ok) throw new Error('Utilisateur introuvable');19 return res.json();20 },21 staleTime: 5 * 60 * 1000,22 });23
24export const usersListQueryOptions = (filters?: { role?: string; page?: number }) =>25 queryOptions({26 queryKey: ['users', 'list', filters ?? {}],27 queryFn: async (): Promise<{ users: User[]; total: number }> => {28 const params = new URLSearchParams();29 if (filters?.role) params.set('role', filters.role);30 if (filters?.page) params.set('page', String(filters.page));31 const res = await fetch(`/api/users?${params}`);32 return res.json();33 },34 staleTime: 30 * 1000,35 });36
37// ---- Utilisation dans les composants ----38
39// Le type de data est infere automatiquement : User40export function UserProfile({ userId }: { userId: number }) {41 const { data: user } = useQuery(userQueryOptions(userId));42 return <div>{user?.name}</div>;43}44
45// Prefetch avec les memes options46export function UserLink({ userId }: { userId: number }) {47 const queryClient = useQueryClient();48
49 const handleMouseEnter = () => {50 // Prefetch au survol : les options sont identiques51 queryClient.prefetchQuery(userQueryOptions(userId));52 };53
54 return (55 <a href={`/users/${userId}`} onMouseEnter={handleMouseEnter}>56 Voir le profil57 </a>58 );59}Query key factory : un module dedie
Le pattern query key factory centralise toutes les query keys d'un domaine dans un objet unique. Chaque methode retourne une query key typee. Cela garantit la coherence entre les queries et les invalidations, et facilite le refactoring.
1// lib/queries/users.ts - Query Key Factory2
3// Le factory centralise toutes les keys d'un domaine4export const userKeys = {5 // Racine du domaine6 all: ['users'] as const,7
8 // Listes avec filtres9 lists: () => [...userKeys.all, 'list'] as const,10 list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,11
12 // Details individuels13 details: () => [...userKeys.all, 'detail'] as const,14 detail: (id: number) => [...userKeys.details(), id] as const,15
16 // Sous-ressources17 posts: (userId: number) => [...userKeys.detail(userId), 'posts'] as const,18 settings: (userId: number) => [...userKeys.detail(userId), 'settings'] as const,19} as const;20
21// Exemple pour un autre domaine22export const postKeys = {23 all: ['posts'] as const,24 lists: () => [...postKeys.all, 'list'] as const,25 list: (filters: PostFilters) => [...postKeys.lists(), filters] as const,26 detail: (id: number) => [...postKeys.all, 'detail', id] as const,27 comments: (postId: number) => [...postKeys.detail(postId), 'comments'] as const,28} as const;29
30// ---- Utilisation ----31
32// Dans les queries33useQuery({34 queryKey: userKeys.detail(42),35 queryFn: () => fetchUser(42),36});37
38useQuery({39 queryKey: userKeys.list({ role: 'admin', page: 1 }),40 queryFn: () => fetchUsers({ role: 'admin', page: 1 }),41});42
43// Dans les invalidations44// Invalider TOUTES les listes utilisateur (quel que soit le filtre)45queryClient.invalidateQueries({ queryKey: userKeys.lists() });46
47// Invalider TOUT ce qui concerne le user 4248queryClient.invalidateQueries({ queryKey: userKeys.detail(42) });49
50// Invalider absolument toutes les queries utilisateur51queryClient.invalidateQueries({ queryKey: userKeys.all });Custom hooks : l'interface finale
Combiner queryOptions avec un query key factory dans des custom hooks offre une API propre et type-safe pour vos composants. Les composants n'ont aucune connaissance de la structure du cache ou des endpoints API.
1// hooks/use-users.ts2
3import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';4import { userKeys } from '@/lib/queries/users';5
6// ---- Hooks de lecture ----7
8export function useUser(userId: number) {9 return useQuery({10 queryKey: userKeys.detail(userId),11 queryFn: () => fetchUser(userId),12 staleTime: 5 * 60 * 1000,13 enabled: userId > 0,14 });15}16
17export function useUsers(filters?: UserFilters) {18 return useQuery({19 queryKey: userKeys.list(filters ?? {}),20 queryFn: () => fetchUsers(filters),21 staleTime: 30 * 1000,22 });23}24
25export function useUserPosts(userId: number) {26 return useQuery({27 queryKey: userKeys.posts(userId),28 queryFn: () => fetchUserPosts(userId),29 enabled: userId > 0,30 });31}32
33// ---- Hooks de mutation ----34
35export function useCreateUser() {36 const queryClient = useQueryClient();37
38 return useMutation({39 mutationFn: createUserOnServer,40 onSuccess: () => {41 queryClient.invalidateQueries({ queryKey: userKeys.lists() });42 },43 });44}45
46export function useUpdateUser() {47 const queryClient = useQueryClient();48
49 return useMutation({50 mutationFn: ({ id, data }: { id: number; data: UpdateUserPayload }) =>51 updateUserOnServer(id, data),52 onSuccess: (updatedUser) => {53 queryClient.setQueryData(userKeys.detail(updatedUser.id), updatedUser);54 queryClient.invalidateQueries({ queryKey: userKeys.lists() });55 },56 });57}58
59export function useDeleteUser() {60 const queryClient = useQueryClient();61
62 return useMutation({63 mutationFn: deleteUserOnServer,64 onSuccess: (_, deletedId) => {65 queryClient.removeQueries({ queryKey: userKeys.detail(deletedId) });66 queryClient.invalidateQueries({ queryKey: userKeys.lists() });67 },68 });69}70
71// ---- Utilisation dans un composant ----72
73export function UserManager() {74 const { data: users, isLoading } = useUsers({ role: 'admin' });75 const createUser = useCreateUser();76 const deleteUser = useDeleteUser();77
78 if (isLoading) return <Skeleton />;79
80 return (81 <div>82 {users?.map((user) => (83 <div key={user.id} className="flex justify-between">84 <span>{user.name}</span>85 <button onClick={() => deleteUser.mutate(user.id)}>86 Supprimer87 </button>88 </div>89 ))}90 </div>91 );92}Prefetching : anticiper les besoins
Le prefetching charge des donnees dans le cache avant que l'utilisateur n'en ait besoin. Quand il navigue vers la page correspondante, les donnees sont deja disponibles et la page s'affiche instantanement. Deux approches : prefetchQuery (imperatif) et le hook usePrefetchQuery (declaratif).
1import {2 useQueryClient,3 usePrefetchQuery,4} from '@tanstack/react-query';5import { userQueryOptions } from '@/lib/queries/users';6
7// ---- Approche imperative : prefetchQuery ----8
9// Prefetch au survol d'un lien10export function UserListItem({ user }: { user: User }) {11 const queryClient = useQueryClient();12
13 return (14 <Link15 href={`/users/${user.id}`}16 onMouseEnter={() => {17 // Prefetch les donnees du profil quand l'utilisateur survole le lien18 queryClient.prefetchQuery(userQueryOptions(user.id));19 }}20 onFocus={() => {21 // Egalement au focus pour l'accessibilite clavier22 queryClient.prefetchQuery(userQueryOptions(user.id));23 }}24 >25 {user.name}26 </Link>27 );28}29
30// Prefetch dans un loader (SSR ou route transition)31export async function prefetchUserPage(queryClient: QueryClient, userId: number) {32 // Prefetch en parallele : profil + posts33 await Promise.all([34 queryClient.prefetchQuery(userQueryOptions(userId)),35 queryClient.prefetchQuery({36 queryKey: ['users', userId, 'posts'],37 queryFn: () => fetchUserPosts(userId),38 }),39 ]);40}41
42// ---- Approche declarative : usePrefetchQuery ----43
44// Le hook prefetch automatiquement au montage du composant45export function UserPageLayout({ userId }: { userId: number }) {46 // Les donnees sont prefetchees des que ce composant est monte47 // Utile pour prefetcher les donnees de la prochaine section visible48 usePrefetchQuery(userQueryOptions(userId));49
50 return (51 <div>52 {/* Ce composant sera monte plus tard, mais les donnees sont deja en cache */}53 <Suspense fallback={<Skeleton />}>54 <UserProfile userId={userId} />55 </Suspense>56 </div>57 );58}Gestion d'erreurs globale et retry
TanStack Query offre une gestion d'erreurs a deux niveaux : global via les callbacks du QueryCache, et local via les options de chaque query. Le retry automatique avec backoff exponentiel est configure par defaut et peut etre personnalise finement.
1import { QueryClient, QueryCache, MutationCache } from '@tanstack/react-query';2import { toast } from 'sonner';3
4// ---- Configuration globale des erreurs ----5
6const queryClient = new QueryClient({7 queryCache: new QueryCache({8 onError: (error, query) => {9 // Notification globale pour toutes les erreurs de query10 // Seulement si la query avait deja des donnees (revalidation echouee)11 if (query.state.data !== undefined) {12 toast.error(`Erreur de mise a jour : ${error.message}`);13 }14 },15 }),16 mutationCache: new MutationCache({17 onError: (error) => {18 // Notification globale pour toutes les erreurs de mutation19 toast.error(`Operation echouee : ${error.message}`);20 },21 }),22 defaultOptions: {23 queries: {24 // ---- Configuration du retry ----25
26 // Nombre de tentatives (defaut: 3)27 retry: 3,28
29 // Fonction personnalisee : ne pas retenter les 40430 retry: (failureCount, error) => {31 if (error instanceof HttpError && error.status === 404) return false;32 if (error instanceof HttpError && error.status === 401) return false;33 return failureCount < 3;34 },35
36 // Delai entre les tentatives (backoff exponentiel par defaut)37 retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),38 // Attempt 0 : 1s, Attempt 1 : 2s, Attempt 2 : 4s (max 30s)39
40 // Gestion globale d'erreur par defaut41 throwOnError: false, // Ne pas propager aux Error Boundaries par defaut42 },43 mutations: {44 // Les mutations ne retentent pas par defaut (donnees pourraient etre dupliquees)45 retry: false,46 },47 },48});49
50// ---- Classe d'erreur personnalisee ----51
52class HttpError extends Error {53 constructor(54 public status: number,55 message: string,56 ) {57 super(message);58 this.name = 'HttpError';59 }60}61
62// Fetch wrapper qui throw des HttpError typees63async function apiFetch<T>(url: string, options?: RequestInit): Promise<T> {64 const response = await fetch(url, {65 ...options,66 headers: {67 'Content-Type': 'application/json',68 ...options?.headers,69 },70 });71
72 if (!response.ok) {73 throw new HttpError(74 response.status,75 `${response.status}: ${response.statusText}`,76 );77 }78
79 return response.json();80}Error Boundaries et TanStack Query
TanStack Query s'integre nativement avec les Error Boundaries de React pour une gestion d'erreurs declarative au niveau du composant ou de la page.
throwOnError
En activant throwOnError: true sur une query, l'erreur est propagee a l'Error Boundary parent au lieu d'etre geree dans le composant. Cela permet de definir un fallback UI au niveau de la page ou de la section, sans polluer chaque composant avec de la logique d'erreur.
useQueryErrorResetBoundary
Ce hook fournit une fonction reset qui permet a l'Error Boundary de relancer les queries echouees quand l'utilisateur clique sur un bouton "Reessayer". Combine avec le composant QueryErrorResetBoundary, il offre une experience de recovery complete.
Strategie recommandee
Utilisez throwOnError pour les queries critiques ou le composant ne peut pas fonctionner sans donnees (profil utilisateur, configuration). Gardez la gestion locale (isError) pour les queries secondaires ou une degradation gracieuse est possible (suggestions, recommandations).
Pourquoi votre routing devrait être type-safe ?
TanStack Router est le premier routeur React a offrir une type-safety complete de bout en bout : chemins, parametres d'URL, search params, loaders -- tout est infere et auto-complete par TypeScript. Fini les erreurs de typo dans les paths ou les search params non valides. Combine avec TanStack Query, il permet de charger les donnees au niveau des routes et d'eliminer les waterfalls de requetes.
Type-safety de bout en bout
TanStack Router est le seul routeur React ou chaque aspect de la navigation est entierement type par TypeScript, sans configuration manuelle des types.
Paths auto-completes
Le composant Link n'accepte que des chemins valides definis dans votre arbre de routes. Une faute de frappe dans le path est detectee a la compilation.
Params types
Les parametres dynamiques ($userId) sont automatiquement disponibles avec leur type correct dans les composants, loaders et hooks.
Search params valides
Les search params sont definis par un schema de validation (Zod, Valibot). Le type est infere automatiquement et les valeurs invalides sont rejetees.
Loaders types
Les donnees chargees dans le loader sont automatiquement typees dans le composant de route via useLoaderData().
File-based routing vs code-based routing
TanStack Router supporte deux approches : le file-based routing (recommande) ou le code-based routing genere par le plugin Vite. Le file-based routing est plus intuitif et suit des conventions similaires a Next.js, mais avec une type-safety complete.
1// ---- FILE-BASED ROUTING ----2// Structure de fichiers (convention TanStack Router)3//4// src/routes/5// |-- __root.tsx # Layout racine6// |-- index.tsx # /7// |-- about.tsx # /about8// |-- users/9// | |-- index.tsx # /users10// | |-- $userId.tsx # /users/:userId (parametre dynamique)11// | |-- $userId/12// | |-- posts.tsx # /users/:userId/posts13// |-- _authenticated/ # Layout group (pas dans l'URL)14// | |-- dashboard.tsx # /dashboard (avec layout authentifie)15// | |-- settings.tsx # /settings16
17// ---- src/routes/__root.tsx ----18import { createRootRoute, Outlet } from '@tanstack/react-router';19
20export const Route = createRootRoute({21 component: () => (22 <div>23 <Header />24 <main className="container mx-auto px-4">25 <Outlet />26 </main>27 <Footer />28 </div>29 ),30 // Error boundary global pour toutes les routes31 errorComponent: ({ error }) => (32 <div className="p-8 text-center">33 <h1 className="text-2xl font-bold mb-4">Erreur inattendue</h1>34 <p className="text-muted-foreground">{error.message}</p>35 </div>36 ),37 // Composant affiche quand aucune route ne correspond38 notFoundComponent: () => (39 <div className="p-8 text-center">40 <h1 className="text-2xl font-bold">Page introuvable</h1>41 </div>42 ),43});44
45// ---- CODE-BASED ROUTING ----46// Alternative : definir les routes en code pur47import { createRoute, createRouter } from '@tanstack/react-router';48
49const rootRoute = createRootRoute({ component: RootLayout });50
51const indexRoute = createRoute({52 getParentRoute: () => rootRoute,53 path: '/',54 component: HomePage,55});56
57const usersRoute = createRoute({58 getParentRoute: () => rootRoute,59 path: '/users',60 component: UsersPage,61});62
63const userRoute = createRoute({64 getParentRoute: () => usersRoute,65 path: '/$userId',66 component: UserDetailPage,67});68
69// Construction de l'arbre de routes70const routeTree = rootRoute.addChildren([71 indexRoute,72 usersRoute.addChildren([userRoute]),73]);74
75// Creation du routeur avec type-safety complete76const router = createRouter({ routeTree });77
78// Declaration du type pour l'auto-completion globale79declare module '@tanstack/react-router' {80 interface Register {81 router: typeof router;82 }83}Loaders : charger les donnees au niveau de la route
Les loaders sont la fonctionnalite qui distingue TanStack Router des autres routeurs React. Ils permettent de charger les donnees avant que le composant de route ne soit rendu, eliminant les waterfalls et les flash de chargement. Les donnees du loader sont typees et accessibles via useLoaderData().
1// src/routes/users/$userId.tsx2import { createFileRoute } from '@tanstack/react-router';3
4// Le loader s'execute AVANT que le composant ne soit rendu5export const Route = createFileRoute('/users/$userId')({6 // Le loader recoit les params de route types automatiquement7 loader: async ({ params }) => {8 // params.userId est type string (comme dans l'URL)9 const userId = parseInt(params.userId, 10);10
11 // Charger les donnees en parallele12 const [user, posts] = await Promise.all([13 fetch(`/api/users/${userId}`).then((r) => r.json()),14 fetch(`/api/users/${userId}/posts`).then((r) => r.json()),15 ]);16
17 return { user, posts };18 },19
20 // Composant de route : les donnees du loader sont deja disponibles21 component: UserDetailPage,22
23 // Affiche pendant le chargement du loader24 pendingComponent: () => <UserDetailSkeleton />,25
26 // Temps minimum d'affichage du pending pour eviter les flash27 pendingMinMs: 200,28
29 // Affiche si le loader echoue30 errorComponent: ({ error }) => (31 <div className="p-4 text-red-500">32 Impossible de charger cet utilisateur : {error.message}33 </div>34 ),35});36
37function UserDetailPage() {38 // useLoaderData() retourne le type exact du retour du loader39 // Ici : { user: User; posts: Post[] }40 const { user, posts } = Route.useLoaderData();41
42 return (43 <div className="grid grid-cols-3 gap-8">44 <div className="col-span-1">45 <UserProfileCard user={user} />46 </div>47 <div className="col-span-2">48 <h2 className="text-xl font-bold mb-4">49 Articles de {user.name}50 </h2>51 {posts.map((post) => (52 <PostCard key={post.id} post={post} />53 ))}54 </div>55 </div>56 );57}Integration native avec TanStack Query
La combinaison TanStack Router + TanStack Query est la plus puissante. Le loader prefetch les donnees dans le cache de TanStack Query, puis le composant utilise useQuery pour lire le cache. Cela offre le meilleur des deux mondes : pas de waterfall ET revalidation automatique.
1// src/routes/users/$userId.tsx2import { createFileRoute } from '@tanstack/react-router';3import { useQuery, useSuspenseQuery } from '@tanstack/react-query';4import { userQueryOptions, userPostsQueryOptions } from '@/lib/queries/users';5
6export const Route = createFileRoute('/users/$userId')({7 // Le loader prefetch les donnees dans le cache TanStack Query8 loader: async ({ context: { queryClient }, params }) => {9 const userId = parseInt(params.userId, 10);10
11 // ensureQueryData : retourne les donnees du cache si disponibles,12 // sinon fetch et met en cache13 await Promise.all([14 queryClient.ensureQueryData(userQueryOptions(userId)),15 queryClient.ensureQueryData(userPostsQueryOptions(userId)),16 ]);17 },18
19 component: UserDetailPage,20});21
22function UserDetailPage() {23 const { userId } = Route.useParams();24 const id = parseInt(userId, 10);25
26 // useSuspenseQuery lit le cache rempli par le loader27 // Les donnees sont GARANTIES d'etre disponibles (pas de isLoading)28 const { data: user } = useSuspenseQuery(userQueryOptions(id));29 const { data: posts } = useSuspenseQuery(userPostsQueryOptions(id));30
31 // La revalidation automatique de TanStack Query continue de fonctionner :32 // - refetch au window focus33 // - refetch selon staleTime34 // - invalidation apres mutation35 // Tout cela sans aucun code supplementaire36
37 return (38 <div>39 <h1 className="text-3xl font-bold">{user.name}</h1>40 <p className="text-muted-foreground">{user.email}</p>41
42 <section className="mt-8">43 <h2 className="text-xl font-bold mb-4">Articles ({posts.length})</h2>44 {posts.map((post) => (45 <PostCard key={post.id} post={post} />46 ))}47 </section>48 </div>49 );50}51
52// ---- Configuration du routeur avec QueryClient ----53import { createRouter } from '@tanstack/react-router';54import { QueryClient } from '@tanstack/react-query';55
56const queryClient = new QueryClient();57
58const router = createRouter({59 routeTree,60 // Passer le queryClient dans le contexte du routeur61 context: { queryClient },62});63
64declare module '@tanstack/react-router' {65 interface Register {66 router: typeof router;67 }68}Search params avec validation
TanStack Router permet de definir un schema de validation pour les search params de chaque route. Les valeurs sont automatiquement parsees, validees et typees. Les search params invalides sont remplaces par les valeurs par defaut.
1// src/routes/users/index.tsx2import { createFileRoute } from '@tanstack/react-router';3import { z } from 'zod';4
5// Schema de validation des search params6const usersSearchSchema = z.object({7 page: z.number().int().positive().default(1).catch(1),8 limit: z.number().int().min(10).max(100).default(20).catch(20),9 role: z.enum(['all', 'admin', 'user', 'editor']).default('all').catch('all'),10 sortBy: z.enum(['name', 'email', 'createdAt']).default('createdAt').catch('createdAt'),11 sortOrder: z.enum(['asc', 'desc']).default('desc').catch('desc'),12 search: z.string().optional().catch(undefined),13});14
15// Le type est infere automatiquement du schema16type UsersSearch = z.infer<typeof usersSearchSchema>;17
18export const Route = createFileRoute('/users/')({19 // Le schema valide et parse les search params20 validateSearch: usersSearchSchema,21
22 component: UsersListPage,23});24
25function UsersListPage() {26 // useSearch() retourne le type exact du schema27 // Chaque propriete est garantie d'avoir le bon type28 const { page, limit, role, sortBy, sortOrder, search } = Route.useSearch();29 const navigate = Route.useNavigate();30
31 return (32 <div>33 {/* Filtres */}34 <div className="flex gap-4 mb-6">35 <select36 value={role}37 onChange={(e) =>38 navigate({39 search: (prev) => ({ ...prev, role: e.target.value as UsersSearch['role'], page: 1 }),40 })41 }42 >43 <option value="all">Tous les roles</option>44 <option value="admin">Administrateurs</option>45 <option value="user">Utilisateurs</option>46 <option value="editor">Editeurs</option>47 </select>48
49 <input50 type="text"51 value={search ?? ''}52 onChange={(e) =>53 navigate({54 search: (prev) => ({55 ...prev,56 search: e.target.value || undefined,57 page: 1,58 }),59 })60 }61 placeholder="Rechercher..."62 />63 </div>64
65 {/* Pagination */}66 <div className="flex gap-2">67 <button68 onClick={() => navigate({ search: (prev) => ({ ...prev, page: page - 1 }) })}69 disabled={page === 1}70 >71 Precedent72 </button>73 <span>Page {page}</span>74 <button75 onClick={() => navigate({ search: (prev) => ({ ...prev, page: page + 1 }) })}76 >77 Suivant78 </button>79 </div>80 </div>81 );82}83
84// ---- Link type-safe avec search params ----85// TypeScript verifie que les search params sont valides86<Link87 to="/users"88 search={{ page: 1, role: 'admin', sortBy: 'name', sortOrder: 'asc', limit: 20 }}89>90 Voir les administrateurs91</Link>- Ecosysteme et communaute massifs
- Documentation mature et complete
- Loaders et actions (v7+)
- Compatible avec tous les meta-frameworks
- Type-safety limitee (paths en string)
- Search params non types nativement
- Pas d'auto-completion sur les routes
- Migration complexe entre versions majeures
- •Applications existantes avec React Router
- •Projets privilegiant la stabilite ecosysteme
- •Equipes familières avec l'API
- Type-safety complete de bout en bout
- Auto-completion paths, params, search params
- Integration native TanStack Query
- Search params avec validation schema
- Loaders avec prefetching
- Ecosysteme plus jeune
- Communaute plus petite
- Courbe d'apprentissage TypeScript exigeante
- Non compatible avec Next.js (SPA seulement)
- •Nouvelles SPA avec TypeScript strict
- •Applications avec TanStack Query
- •Projets valorisant la type-safety maximale
- Server Components natifs
- Streaming et Suspense integres
- Layouts imbriques puissants
- SEO et performance SSR optimaux
- Pas de configuration routing
- Couple a Next.js (pas portable)
- Type-safety des params limitee
- Search params non valides nativement
- Complexite Server/Client boundary
- •Applications full-stack avec SSR
- •Sites avec fort besoin SEO
- •Projets utilisant l'ecosysteme Vercel
React Router | TanStack Router | Next.js Router |
|---|---|---|
Le routeur React historique, le plus utilise. API declarative, comunaute massive, integration large. v7 introduit les loaders et actions inspires de Remix. | Routeur 100% type-safe avec inference TypeScript complete. Loaders natifs, search params valides, integration TanStack Query, et auto-completion sur tous les aspects de la navigation. | Routeur file-based integre a Next.js. App Router avec Server Components, Server Actions, layouts imbriques et streaming. Optimise pour le rendu serveur. |
Avantages
| Avantages
| Avantages
|
Inconvenients
| Inconvenients
| Inconvenients
|
Cas d'usage
| Cas d'usage
| Cas d'usage
|
Comment créer des tableaux complexes sans librairie UI ?
TanStack Table est une librairie headless pour construire des tableaux et datagrids puissants. Headless signifie qu'elle gere toute la logique (tri, filtrage, pagination, selection, groupement) mais ne rend aucun markup HTML. Vous gardez le controle total du rendu, du style et de l'accessibilite. C'est la librairie de tableaux la plus telechargee de l'ecosysteme React avec plus de 3 millions de telechargements hebdomadaires.
Philosophie headless
Comprendre ce que signifie headless et pourquoi c'est un avantage determinant pour les projets en production.
- -- Zero markup impose : vous utilisez vos propres composants (div, table, ou meme canvas)
- -- Zero CSS impose : compatible Tailwind, CSS Modules, styled-components, ou n'importe quel systeme
- -- Logique pure : le core ne depend pas de React. Des adaptateurs existent pour Vue, Solid, Svelte, Lit
- -- Tree-shakable : n'importez que les fonctionnalites utilisees (sorting, filtering, pagination...)
- -- TypeScript first : generics sur les donnees, colonnes et cellules entierement types
Installation et configuration de base
1import {2 useReactTable,3 getCoreRowModel,4 flexRender,5 type ColumnDef,6} from '@tanstack/react-table';7
8interface User {9 id: string;10 name: string;11 email: string;12 role: 'admin' | 'editor' | 'viewer';13 createdAt: Date;14}15
16// 1. Definir les colonnes avec typage complet17const columns: ColumnDef<User>[] = [18 {19 accessorKey: 'name',20 header: 'Nom',21 cell: (info) => (22 <span className="font-medium">{info.getValue<string>()}</span>23 ),24 },25 {26 accessorKey: 'email',27 header: 'Email',28 },29 {30 accessorKey: 'role',31 header: 'Role',32 cell: (info) => {33 const role = info.getValue<string>();34 const colors: Record<string, string> = {35 admin: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',36 editor: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',37 viewer: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400',38 };39 return (40 <span className={`text-xs px-2 py-1 rounded-full ${colors[role]}`}>41 {role}42 </span>43 );44 },45 },46 {47 accessorKey: 'createdAt',48 header: 'Inscription',49 cell: (info) =>50 new Intl.DateTimeFormat('fr-FR').format(info.getValue<Date>()),51 },52];53
54// 2. Creer et utiliser la table55function BasicTable({ data }: { data: User[] }) {56 const table = useReactTable({57 data,58 columns,59 getCoreRowModel: getCoreRowModel(),60 });61
62 return (63 <table className="w-full border-collapse">64 <thead>65 {table.getHeaderGroups().map((headerGroup) => (66 <tr key={headerGroup.id} className="border-b">67 {headerGroup.headers.map((header) => (68 <th69 key={header.id}70 className="px-4 py-3 text-left text-sm font-semibold"71 >72 {header.isPlaceholder73 ? null74 : flexRender(header.column.columnDef.header, header.getContext())}75 </th>76 ))}77 </tr>78 ))}79 </thead>80 <tbody>81 {table.getRowModel().rows.map((row) => (82 <tr key={row.id} className="border-b hover:bg-muted/30">83 {row.getVisibleCells().map((cell) => (84 <td key={cell.id} className="px-4 py-3 text-sm">85 {flexRender(cell.column.columnDef.cell, cell.getContext())}86 </td>87 ))}88 </tr>89 ))}90 </tbody>91 </table>92 );93}Tri multi-colonnes
Le tri est la fonctionnalite la plus demandee sur les tableaux. TanStack Table gere nativement le tri sur une ou plusieurs colonnes, avec un controle total sur l'algorithme de comparaison et l'indicateur visuel.
1import {2 useReactTable,3 getCoreRowModel,4 getSortedRowModel,5 type SortingState,6} from '@tanstack/react-table';7import { useState } from 'react';8
9function SortableTable({ data, columns }: TableProps) {10 // State du tri : gere par React, lu par TanStack Table11 const [sorting, setSorting] = useState<SortingState>([]);12
13 const table = useReactTable({14 data,15 columns,16 state: { sorting },17 onSortingChange: setSorting,18 getCoreRowModel: getCoreRowModel(),19 getSortedRowModel: getSortedRowModel(),20 // Options de tri21 enableMultiSort: true, // Shift+clic pour trier sur plusieurs colonnes22 enableSortingRemoval: true, // 3eme clic retire le tri23 maxMultiSortColCount: 3, // Maximum 3 colonnes de tri simultanees24 });25
26 // Dans le header :27 // <th onClick={header.column.getToggleSortingHandler()}>28 // {header.column.columnDef.header}29 // {{ asc: ' ↑', desc: ' ↓' }[header.column.getIsSorted() as string] ?? ''}30 // </th>31
32 // Tri personnalise par colonne :33 const customColumns: ColumnDef<User>[] = [34 {35 accessorKey: 'name',36 header: 'Nom',37 sortingFn: 'text', // Tri alphabetique natif38 },39 {40 accessorKey: 'createdAt',41 header: 'Date',42 sortingFn: 'datetime', // Tri chronologique natif43 },44 {45 accessorKey: 'priority',46 header: 'Priorite',47 // Tri personnalise : haute > moyenne > basse48 sortingFn: (rowA, rowB, columnId) => {49 const order = { haute: 3, moyenne: 2, basse: 1 };50 const a = order[rowA.getValue<string>(columnId) as keyof typeof order];51 const b = order[rowB.getValue<string>(columnId) as keyof typeof order];52 return a - b;53 },54 },55 ];56
57 return table;58}Filtrage global et par colonne
1import {2 useReactTable,3 getCoreRowModel,4 getFilteredRowModel,5 type ColumnFiltersState,6} from '@tanstack/react-table';7import { useState } from 'react';8
9function FilterableTable({ data, columns }: TableProps) {10 const [globalFilter, setGlobalFilter] = useState('');11 const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);12
13 const table = useReactTable({14 data,15 columns,16 state: {17 globalFilter,18 columnFilters,19 },20 onGlobalFilterChange: setGlobalFilter,21 onColumnFiltersChange: setColumnFilters,22 getCoreRowModel: getCoreRowModel(),23 getFilteredRowModel: getFilteredRowModel(),24 // Filtre global : cherche dans toutes les colonnes25 globalFilterFn: 'includesString',26 });27
28 return (29 <div className="space-y-4">30 {/* Recherche globale */}31 <input32 value={globalFilter}33 onChange={(e) => setGlobalFilter(e.target.value)}34 placeholder="Rechercher dans toutes les colonnes..."35 className="w-full px-4 py-2 border rounded-lg"36 />37
38 {/* Filtres par colonne */}39 <div className="flex gap-2">40 {table.getHeaderGroups()[0].headers.map((header) => (41 <input42 key={header.id}43 value={(header.column.getFilterValue() as string) ?? ''}44 onChange={(e) => header.column.setFilterValue(e.target.value)}45 placeholder={`Filtrer ${header.column.columnDef.header}...`}46 className="px-3 py-1 text-sm border rounded"47 />48 ))}49 </div>50
51 {/* Indicateur de resultats */}52 <p className="text-sm text-muted-foreground">53 {table.getFilteredRowModel().rows.length} resultat(s)54 sur {data.length} lignes55 </p>56
57 {/* ... rendu du tableau ... */}58 </div>59 );60
61 // Filtre personnalise par colonne :62 // {63 // accessorKey: 'role',64 // filterFn: (row, columnId, filterValue) => {65 // return row.getValue<string>(columnId) === filterValue;66 // },67 // }68}Pagination
1import {2 useReactTable,3 getCoreRowModel,4 getPaginationRowModel,5 type PaginationState,6} from '@tanstack/react-table';7import { useState } from 'react';8
9function PaginatedTable({ data, columns }: TableProps) {10 const [pagination, setPagination] = useState<PaginationState>({11 pageIndex: 0,12 pageSize: 10,13 });14
15 const table = useReactTable({16 data,17 columns,18 state: { pagination },19 onPaginationChange: setPagination,20 getCoreRowModel: getCoreRowModel(),21 getPaginationRowModel: getPaginationRowModel(),22 });23
24 return (25 <div className="space-y-4">26 {/* ... rendu du tableau ... */}27
28 {/* Controles de pagination */}29 <div className="flex items-center justify-between">30 <div className="flex items-center gap-2">31 <button32 onClick={() => table.firstPage()}33 disabled={!table.getCanPreviousPage()}34 className="px-3 py-1 rounded border disabled:opacity-50"35 >36 Debut37 </button>38 <button39 onClick={() => table.previousPage()}40 disabled={!table.getCanPreviousPage()}41 className="px-3 py-1 rounded border disabled:opacity-50"42 >43 Precedent44 </button>45 <button46 onClick={() => table.nextPage()}47 disabled={!table.getCanNextPage()}48 className="px-3 py-1 rounded border disabled:opacity-50"49 >50 Suivant51 </button>52 <button53 onClick={() => table.lastPage()}54 disabled={!table.getCanNextPage()}55 className="px-3 py-1 rounded border disabled:opacity-50"56 >57 Fin58 </button>59 </div>60
61 <div className="flex items-center gap-4 text-sm">62 <span>63 Page {table.getState().pagination.pageIndex + 1} sur{' '}64 {table.getPageCount().toLocaleString()}65 </span>66 <select67 value={table.getState().pagination.pageSize}68 onChange={(e) => table.setPageSize(Number(e.target.value))}69 className="px-2 py-1 border rounded"70 >71 {[10, 20, 50, 100].map((pageSize) => (72 <option key={pageSize} value={pageSize}>73 {pageSize} par page74 </option>75 ))}76 </select>77 </div>78 </div>79 </div>80 );81}Selection, visibilite et redimensionnement
1import {2 useReactTable,3 getCoreRowModel,4 type RowSelectionState,5 type VisibilityState,6} from '@tanstack/react-table';7import { useState } from 'react';8
9function AdvancedTable({ data, columns }: TableProps) {10 // -- Selection de lignes11 const [rowSelection, setRowSelection] = useState<RowSelectionState>({});12
13 // -- Visibilite des colonnes14 const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({15 email: true,16 role: true,17 createdAt: false, // Colonne masquee par defaut18 });19
20 const table = useReactTable({21 data,22 columns,23 state: {24 rowSelection,25 columnVisibility,26 },27 onRowSelectionChange: setRowSelection,28 onColumnVisibilityChange: setColumnVisibility,29 getCoreRowModel: getCoreRowModel(),30 enableRowSelection: true,31 // Selection conditionnelle :32 // enableRowSelection: (row) => row.original.role !== 'admin',33 });34
35 // Colonne de checkbox pour la selection36 const selectionColumn: ColumnDef<User> = {37 id: 'select',38 header: ({ table }) => (39 <input40 type="checkbox"41 checked={table.getIsAllRowsSelected()}42 onChange={table.getToggleAllRowsSelectedHandler()}43 />44 ),45 cell: ({ row }) => (46 <input47 type="checkbox"48 checked={row.getIsSelected()}49 disabled={!row.getCanSelect()}50 onChange={row.getToggleSelectedHandler()}51 />52 ),53 size: 40,54 };55
56 // Toggle visibilite des colonnes57 const ColumnToggle = () => (58 <div className="flex gap-2 mb-4">59 {table.getAllLeafColumns().map((column) => (60 <label key={column.id} className="flex items-center gap-1 text-sm">61 <input62 type="checkbox"63 checked={column.getIsVisible()}64 onChange={column.getToggleVisibilityHandler()}65 />66 {column.id}67 </label>68 ))}69 </div>70 );71
72 // Indicateur de selection73 const selectedCount = Object.keys(rowSelection).length;74 // selectedCount donne le nombre de lignes selectionnees75
76 return { table, ColumnToggle, selectedCount };77}Groupement et agregation
1import {2 useReactTable,3 getCoreRowModel,4 getGroupedRowModel,5 getExpandedRowModel,6 type GroupingState,7} from '@tanstack/react-table';8import { useState } from 'react';9
10function GroupedTable({ data, columns }: TableProps) {11 const [grouping, setGrouping] = useState<GroupingState>(['department']);12
13 const table = useReactTable({14 data,15 columns: [16 {17 accessorKey: 'department',18 header: 'Departement',19 // Fonction d'agregation pour le groupe20 aggregationFn: 'count',21 aggregatedCell: ({ getValue }) =>22 `${getValue()} employe(s)`,23 },24 {25 accessorKey: 'salary',26 header: 'Salaire',27 // Agregation : moyenne des salaires par departement28 aggregationFn: 'mean',29 aggregatedCell: ({ getValue }) =>30 `Moyenne: ${Math.round(getValue<number>()).toLocaleString('fr-FR')} EUR`,31 },32 {33 accessorKey: 'name',34 header: 'Nom',35 },36 ],37 state: { grouping },38 onGroupingChange: setGrouping,39 getCoreRowModel: getCoreRowModel(),40 getGroupedRowModel: getGroupedRowModel(),41 getExpandedRowModel: getExpandedRowModel(),42 });43
44 // Rendu avec gestion des groupes45 // {table.getRowModel().rows.map((row) => (46 // <tr key={row.id}>47 // {row.getVisibleCells().map((cell) => (48 // <td key={cell.id}>49 // {cell.getIsGrouped() ? (50 // // Cellule de groupe : bouton expand/collapse51 // <button onClick={row.getToggleExpandedHandler()}>52 // {row.getIsExpanded() ? '▼' : '▶'}{' '}53 // {flexRender(cell.column.columnDef.cell, cell.getContext())}54 // </button>55 // ) : cell.getIsAggregated() ? (56 // // Cellule agregee : affiche le resultat d'agregation57 // flexRender(cell.column.columnDef.aggregatedCell, cell.getContext())58 // ) : cell.getIsPlaceholder() ? null : (59 // // Cellule normale60 // flexRender(cell.column.columnDef.cell, cell.getContext())61 // )}62 // </td>63 // ))}64 // </tr>65 // ))}66
67 return table;68
69 // Fonctions d'agregation disponibles :70 // 'sum' - Somme des valeurs71 // 'min' - Valeur minimale72 // 'max' - Valeur maximale73 // 'extent' - [min, max]74 // 'mean' - Moyenne75 // 'median' - Mediane76 // 'unique' - Valeurs uniques77 // 'uniqueCount' - Nombre de valeurs uniques78 // 'count' - Nombre d'elements79}- Controle total du rendu et du style
- Bundle leger (tree-shakable, ~15 KB)
- TypeScript first avec generics complets
- Compatible tous les frameworks UI (Tailwind, MUI, etc.)
- Extensible via plugins et fonctions personnalisees
- Plus de code a ecrire pour le rendu
- Pas de composants pre-faits
- Courbe d'apprentissage pour les features avancees
- •Projets avec design system personnalise
- •Dashboards et back-offices sur mesure
- •Applications multi-framework
- Fonctionnalites enterprise tres completes
- Rendu integre et performant (canvas)
- Excel export, pivot tables, charts integres
- Licence payante pour les features avancees
- Bundle lourd (~300 KB min)
- Style difficile a personnaliser en profondeur
- Lock-in sur l'API AG Grid
- •Applications financieres et trading
- •ERP et outils enterprise lourds
- •Besoin d'export Excel natif
- Integration native Material UI
- Bonne documentation
- Version communautaire gratuite
- Lie a Material UI (difficile a utiliser avec Tailwind)
- Fonctionnalites avancees payantes (Pro/Premium)
- Bundle consequent avec la dependance MUI
- Personnalisation limitee par le theme MUI
- •Projets deja bases sur Material UI
- •Prototypage rapide avec Material Design
- •Applications internes sans exigence de design
TanStack Table | AG Grid | Material UI DataGrid |
|---|---|---|
Librairie headless, zero markup, logique pure avec adaptateurs multi-framework | Datagrid complet avec rendu integre, orientee enterprise avec licence commerciale | Datagrid integree a l'ecosysteme Material UI avec rendu Material Design |
Avantages
| Avantages
| Avantages
|
Inconvenients
| Inconvenients
| Inconvenients
|
Cas d'usage
| Cas d'usage
| Cas d'usage
|
Checklist tableau de production
Points essentiels a verifier avant de livrer un tableau TanStack Table en production.
- -- Accessibilite : utiliser des elements table/thead/tbody semantiques, ajouter scope="col" sur les th, aria-sort sur les colonnes triees
- -- Performance : virtualiser avec TanStack Virtual au-dela de 100 lignes, memoiser les colonnes avec useMemo
- -- Responsive : masquer les colonnes secondaires sur mobile avec columnVisibility, ou basculer vers une vue carte
- -- Etat persistant : sauvegarder le tri, les filtres et la pagination dans l'URL (searchParams) pour le partage de liens
- -- Loading states : afficher un skeleton pendant le chargement des donnees, desactiver les controles pendant les requetes
- -- Empty state : prevoir un message quand le filtrage ne retourne aucun resultat
Comment afficher 100k lignes sans lag ?
Rendre 10 000 elements dans le DOM est un chemin direct vers le blocage du thread principal. TanStack Virtual resout ce probleme en ne rendant que les elements visibles dans le viewport, tout en maintenant un defilement fluide a 60 images par seconde. La librairie ne pese que ~3 KB et ne fait aucune hypothese sur votre couche de rendu.
Quand virtualiser une liste
La virtualisation n'est pas toujours necessaire. Voici les indicateurs concrets pour prendre la decision.
- -- Plus de 100 elements : le DOM commence a peser sur les performances de scroll
- -- Elements complexes : chaque ligne contient des composants imbriques, des images ou des interactions
- -- Mesure avant tout : utiliser le React Profiler ou les Chrome DevTools Performance pour identifier si le rendu DOM est le goulot
- -- Mobile en priorite : les appareils mobiles sont 3 a 5 fois plus lents que les desktops pour la manipulation DOM
- -- Seuil pratique : si le Time to Interactive depasse 100ms apres un scroll, la virtualisation est justifiee
useVirtualizer : trois modes de virtualisation
1import { useVirtualizer } from '@tanstack/react-virtual';2import { useRef } from 'react';3
4// -- Liste verticale (cas le plus courant)5function VirtualList({ items }: { items: string[] }) {6 const parentRef = useRef<HTMLDivElement>(null);7
8 const virtualizer = useVirtualizer({9 count: items.length,10 getScrollElement: () => parentRef.current,11 estimateSize: () => 48, // hauteur estimee par element12 overscan: 5, // elements pre-rendus hors viewport13 });14
15 return (16 <div17 ref={parentRef}18 className="h-[500px] overflow-auto"19 >20 <div21 style={{22 height: `${virtualizer.getTotalSize()}px`,23 width: '100%',24 position: 'relative',25 }}26 >27 {virtualizer.getVirtualItems().map((virtualItem) => (28 <div29 key={virtualItem.key}30 style={{31 position: 'absolute',32 top: 0,33 left: 0,34 width: '100%',35 height: `${virtualItem.size}px`,36 transform: `translateY(${virtualItem.start}px)`,37 }}38 >39 {items[virtualItem.index]}40 </div>41 ))}42 </div>43 </div>44 );45}46
47// -- Liste horizontale (carousel, timeline)48function HorizontalVirtualList({ items }: { items: string[] }) {49 const parentRef = useRef<HTMLDivElement>(null);50
51 const virtualizer = useVirtualizer({52 horizontal: true,53 count: items.length,54 getScrollElement: () => parentRef.current,55 estimateSize: () => 200, // largeur estimee par element56 overscan: 3,57 });58
59 return (60 <div ref={parentRef} className="overflow-x-auto">61 <div62 style={{63 width: `${virtualizer.getTotalSize()}px`,64 height: '200px',65 position: 'relative',66 }}67 >68 {virtualizer.getVirtualItems().map((virtualItem) => (69 <div70 key={virtualItem.key}71 style={{72 position: 'absolute',73 top: 0,74 left: 0,75 height: '100%',76 width: `${virtualItem.size}px`,77 transform: `translateX(${virtualItem.start}px)`,78 }}79 >80 {items[virtualItem.index]}81 </div>82 ))}83 </div>84 </div>85 );86}87
88// -- Grille virtualisee (galerie, dashboard)89function VirtualGrid({ items }: { items: string[] }) {90 const parentRef = useRef<HTMLDivElement>(null);91 const columns = 4;92
93 const rowVirtualizer = useVirtualizer({94 count: Math.ceil(items.length / columns),95 getScrollElement: () => parentRef.current,96 estimateSize: () => 200,97 overscan: 2,98 });99
100 const columnVirtualizer = useVirtualizer({101 horizontal: true,102 count: columns,103 getScrollElement: () => parentRef.current,104 estimateSize: () => 250,105 overscan: 1,106 });107
108 return (109 <div ref={parentRef} className="h-[600px] overflow-auto">110 <div111 style={{112 height: `${rowVirtualizer.getTotalSize()}px`,113 width: `${columnVirtualizer.getTotalSize()}px`,114 position: 'relative',115 }}116 >117 {rowVirtualizer.getVirtualItems().map((virtualRow) =>118 columnVirtualizer.getVirtualItems().map((virtualCol) => {119 const index = virtualRow.index * columns + virtualCol.index;120 if (index >= items.length) return null;121
122 return (123 <div124 key={`${virtualRow.key}-${virtualCol.key}`}125 style={{126 position: 'absolute',127 top: 0,128 left: 0,129 width: `${virtualCol.size}px`,130 height: `${virtualRow.size}px`,131 transform: `translateX(${virtualCol.start}px) translateY(${virtualRow.start}px)`,132 }}133 >134 {items[index]}135 </div>136 );137 })138 )}139 </div>140 </div>141 );142}Strategies de dimensionnement
1import { useVirtualizer } from '@tanstack/react-virtual';2import { useRef, useCallback } from 'react';3
4// -- Taille fixe : tous les elements ont la meme hauteur5const fixedVirtualizer = useVirtualizer({6 count: 10000,7 getScrollElement: () => parentRef.current,8 estimateSize: () => 48, // valeur exacte, pas d'estimation9});10
11// -- Taille variable : hauteur connue a l'avance par element12const variableVirtualizer = useVirtualizer({13 count: messages.length,14 getScrollElement: () => parentRef.current,15 estimateSize: (index) => {16 // Hauteur differente selon le type de message17 const message = messages[index];18 if (message.type === 'image') return 300;19 if (message.type === 'code') return 200;20 return 64; // message texte standard21 },22});23
24// -- Taille dynamique mesuree : hauteur inconnue, mesuree au rendu25function DynamicSizeList({ items }: { items: ChatMessage[] }) {26 const parentRef = useRef<HTMLDivElement>(null);27
28 const virtualizer = useVirtualizer({29 count: items.length,30 getScrollElement: () => parentRef.current,31 estimateSize: () => 80, // estimation initiale32 measureElement: (element) => {33 // Mesure reelle du DOM apres le rendu34 return element.getBoundingClientRect().height;35 },36 });37
38 return (39 <div ref={parentRef} className="h-[600px] overflow-auto">40 <div41 style={{42 height: `${virtualizer.getTotalSize()}px`,43 position: 'relative',44 }}45 >46 {virtualizer.getVirtualItems().map((virtualItem) => (47 <div48 key={virtualItem.key}49 data-index={virtualItem.index}50 ref={virtualizer.measureElement}51 style={{52 position: 'absolute',53 top: 0,54 left: 0,55 width: '100%',56 transform: `translateY(${virtualItem.start}px)`,57 }}58 >59 <ChatBubble message={items[virtualItem.index]} />60 </div>61 ))}62 </div>63 </div>64 );65}Integration avec TanStack Table
1import { useVirtualizer } from '@tanstack/react-virtual';2import {3 useReactTable,4 getCoreRowModel,5 getSortedRowModel,6 flexRender,7 type ColumnDef,8} from '@tanstack/react-table';9import { useRef } from 'react';10
11interface User {12 id: string;13 name: string;14 email: string;15 role: string;16 lastActive: Date;17}18
19const columns: ColumnDef<User>[] = [20 { accessorKey: 'name', header: 'Nom' },21 { accessorKey: 'email', header: 'Email' },22 { accessorKey: 'role', header: 'Role' },23 {24 accessorKey: 'lastActive',25 header: 'Derniere activite',26 cell: ({ getValue }) =>27 new Intl.DateTimeFormat('fr-FR').format(getValue<Date>()),28 },29];30
31function VirtualizedTable({ data }: { data: User[] }) {32 const parentRef = useRef<HTMLDivElement>(null);33
34 const table = useReactTable({35 data,36 columns,37 getCoreRowModel: getCoreRowModel(),38 getSortedRowModel: getSortedRowModel(),39 });40
41 const { rows } = table.getRowModel();42
43 const virtualizer = useVirtualizer({44 count: rows.length,45 getScrollElement: () => parentRef.current,46 estimateSize: () => 52,47 overscan: 10,48 });49
50 return (51 <div ref={parentRef} className="h-[600px] overflow-auto rounded-lg border">52 <table className="w-full">53 <thead className="sticky top-0 bg-background z-10 border-b">54 {table.getHeaderGroups().map((headerGroup) => (55 <tr key={headerGroup.id}>56 {headerGroup.headers.map((header) => (57 <th58 key={header.id}59 className="px-4 py-3 text-left text-sm font-semibold cursor-pointer hover:bg-muted/50"60 onClick={header.column.getToggleSortingHandler()}61 >62 {flexRender(header.column.columnDef.header, header.getContext())}63 </th>64 ))}65 </tr>66 ))}67 </thead>68 <tbody>69 <tr>70 <td71 colSpan={columns.length}72 style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}73 >74 {virtualizer.getVirtualItems().map((virtualRow) => {75 const row = rows[virtualRow.index];76 return (77 <tr78 key={row.id}79 className="absolute w-full flex border-b hover:bg-muted/30"80 style={{81 height: `${virtualRow.size}px`,82 transform: `translateY(${virtualRow.start}px)`,83 }}84 >85 {row.getVisibleCells().map((cell) => (86 <td key={cell.id} className="px-4 py-3 text-sm flex-1">87 {flexRender(cell.column.columnDef.cell, cell.getContext())}88 </td>89 ))}90 </tr>91 );92 })}93 </td>94 </tr>95 </tbody>96 </table>97 </div>98 );99}100
101// Utilisation : <VirtualizedTable data={tenThousandUsers} />Exemple complet : 10 000 elements
1'use client';2
3import { useVirtualizer } from '@tanstack/react-virtual';4import { useRef, useMemo } from 'react';5
6interface Product {7 id: number;8 name: string;9 price: number;10 category: string;11 inStock: boolean;12}13
14// Generation de 10 000 produits pour la demonstration15function generateProducts(count: number): Product[] {16 const categories = ['Electronique', 'Vetements', 'Maison', 'Sport', 'Alimentation'];17 return Array.from({ length: count }, (_, i) => ({18 id: i + 1,19 name: `Produit ${(i + 1).toString().padStart(5, '0')}`,20 price: Math.round(Math.random() * 500 * 100) / 100,21 category: categories[i % categories.length],22 inStock: Math.random() > 0.2,23 }));24}25
26export function ProductCatalog() {27 const parentRef = useRef<HTMLDivElement>(null);28 const products = useMemo(() => generateProducts(10_000), []);29
30 const virtualizer = useVirtualizer({31 count: products.length,32 getScrollElement: () => parentRef.current,33 estimateSize: () => 72,34 overscan: 8,35 });36
37 return (38 <div className="space-y-4">39 <div className="flex items-center justify-between">40 <h2 className="text-lg font-semibold">41 Catalogue : {products.length.toLocaleString('fr-FR')} produits42 </h2>43 <span className="text-sm text-muted-foreground">44 {virtualizer.getVirtualItems().length} elements rendus dans le DOM45 </span>46 </div>47
48 <div49 ref={parentRef}50 className="h-[500px] overflow-auto rounded-xl border border-border/50"51 >52 <div53 style={{54 height: `${virtualizer.getTotalSize()}px`,55 width: '100%',56 position: 'relative',57 }}58 >59 {virtualizer.getVirtualItems().map((virtualItem) => {60 const product = products[virtualItem.index];61 return (62 <div63 key={virtualItem.key}64 className="absolute top-0 left-0 w-full px-4 py-3 border-b border-border/30 flex items-center justify-between hover:bg-muted/30 transition-colors"65 style={{66 height: `${virtualItem.size}px`,67 transform: `translateY(${virtualItem.start}px)`,68 }}69 >70 <div className="flex items-center gap-4">71 <span className="text-xs text-muted-foreground font-mono w-12">72 #{product.id}73 </span>74 <div>75 <p className="font-medium text-sm">{product.name}</p>76 <p className="text-xs text-muted-foreground">{product.category}</p>77 </div>78 </div>79 <div className="flex items-center gap-4">80 <span className="font-semibold text-sm">81 {product.price.toFixed(2)} EUR82 </span>83 <span84 className={`text-xs px-2 py-0.5 rounded-full ${85 product.inStock86 ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'87 : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'88 }`}89 >90 {product.inStock ? 'En stock' : 'Rupture'}91 </span>92 </div>93 </div>94 );95 })}96 </div>97 </div>98 </div>99 );100}Points de vigilance en production
La virtualisation introduit des contraintes specifiques a prendre en compte avant la mise en production.
- -- Accessibilite : les lecteurs d'ecran ne voient que les elements rendus. Ajouter aria-rowcount et aria-rowindex pour les tableaux
- -- Recherche navigateur : Ctrl+F ne trouve pas les elements hors viewport. Prevoir un champ de recherche applicatif
- -- SEO : le contenu virtualise n'est pas indexable. Pour le contenu public, preferer la pagination serveur
- -- Scroll restoration : utiliser virtualizer.scrollToIndex() pour restaurer la position apres navigation
- -- Overscan : ajuster la valeur selon la vitesse de scroll. 5-10 elements est un bon point de depart
Comment gérer des formulaires sans re-renders inutiles ?
TanStack Form adopte une architecture fondamentalement differente des librairies de formulaires traditionnelles. En s'appuyant sur @tanstack/store, chaque champ possede son propre abonnement reactif. Le resultat : seul le champ modifie se re-rend, pas le formulaire entier. Cette granularite devient critique dans les formulaires complexes avec des dizaines de champs.
Avantage de performance : re-renders granulaires
Dans un formulaire de 50 champs, une saisie dans un champ ne provoque qu'un seul re-render au lieu de 50. C'est le principe fondamental de TanStack Form.
- -- React Hook Form avec watch() : re-rend le composant parent a chaque changement, propageant aux enfants non memoises
- -- TanStack Form : chaque champ souscrit independamment au store. Les autres champs ne sont jamais notifies
- -- Impact mesurable : sur un formulaire de 30+ champs, la difference de reactivite est perceptible a l'oeil nu
useForm et l'API Field
1'use client';2
3import { useForm } from '@tanstack/react-form';4
5interface UserProfile {6 firstName: string;7 lastName: string;8 email: string;9 bio: string;10 role: 'developer' | 'designer' | 'manager';11}12
13export function ProfileForm() {14 const form = useForm<UserProfile>({15 defaultValues: {16 firstName: '',17 lastName: '',18 email: '',19 bio: '',20 role: 'developer',21 },22 onSubmit: async ({ value }) => {23 // value est type-safe : UserProfile24 const response = await fetch('/api/profile', {25 method: 'PUT',26 headers: { 'Content-Type': 'application/json' },27 body: JSON.stringify(value),28 });29
30 if (!response.ok) {31 throw new Error('Erreur lors de la sauvegarde du profil');32 }33 },34 });35
36 return (37 <form38 onSubmit={(e) => {39 e.preventDefault();40 e.stopPropagation();41 form.handleSubmit();42 }}43 className="space-y-6"44 >45 {/* Chaque field ne re-rend que lui-meme */}46 <form.Field47 name="firstName"48 children={(field) => (49 <div className="space-y-2">50 <label htmlFor={field.name} className="text-sm font-medium">51 Prenom52 </label>53 <input54 id={field.name}55 value={field.state.value}56 onChange={(e) => field.handleChange(e.target.value)}57 onBlur={field.handleBlur}58 className="w-full rounded-md border px-3 py-2"59 />60 {field.state.meta.errors.length > 0 && (61 <p className="text-sm text-red-500">62 {field.state.meta.errors.join(', ')}63 </p>64 )}65 </div>66 )}67 />68
69 <form.Field70 name="email"71 children={(field) => (72 <div className="space-y-2">73 <label htmlFor={field.name} className="text-sm font-medium">74 Email75 </label>76 <input77 id={field.name}78 type="email"79 value={field.state.value}80 onChange={(e) => field.handleChange(e.target.value)}81 onBlur={field.handleBlur}82 className="w-full rounded-md border px-3 py-2"83 />84 </div>85 )}86 />87
88 <form.Field89 name="role"90 children={(field) => (91 <div className="space-y-2">92 <label htmlFor={field.name} className="text-sm font-medium">93 Role94 </label>95 <select96 id={field.name}97 value={field.state.value}98 onChange={(e) => field.handleChange(e.target.value as UserProfile['role'])}99 className="w-full rounded-md border px-3 py-2"100 >101 <option value="developer">Developpeur</option>102 <option value="designer">Designer</option>103 <option value="manager">Manager</option>104 </select>105 </div>106 )}107 />108
109 <form.Subscribe110 selector={(state) => [state.canSubmit, state.isSubmitting]}111 children={([canSubmit, isSubmitting]) => (112 <button113 type="submit"114 disabled={!canSubmit}115 className="px-4 py-2 rounded-md bg-primary text-primary-foreground disabled:opacity-50"116 >117 {isSubmitting ? 'Enregistrement...' : 'Enregistrer le profil'}118 </button>119 )}120 />121 </form>122 );123}Validation synchrone, asynchrone et debounced
1import { useForm } from '@tanstack/react-form';2
3export function RegistrationForm() {4 const form = useForm({5 defaultValues: {6 username: '',7 email: '',8 password: '',9 },10 onSubmit: async ({ value }) => {11 await registerUser(value);12 },13 });14
15 return (16 <form onSubmit={(e) => { e.preventDefault(); form.handleSubmit(); }}>17 {/* Validation synchrone : executee a chaque changement */}18 <form.Field19 name="username"20 validators={{21 onChange: ({ value }) => {22 if (value.length < 3) {23 return 'Le nom d\'utilisateur doit contenir au moins 3 caracteres';24 }25 if (!/^[a-zA-Z0-9_-]+$/.test(value)) {26 return 'Caracteres autorises : lettres, chiffres, tirets et underscores';27 }28 return undefined;29 },30 }}31 children={(field) => (32 <div className="space-y-2">33 <label className="text-sm font-medium">Nom d'utilisateur</label>34 <input35 value={field.state.value}36 onChange={(e) => field.handleChange(e.target.value)}37 onBlur={field.handleBlur}38 className="w-full rounded-md border px-3 py-2"39 />40 {field.state.meta.errors.map((error, i) => (41 <p key={i} className="text-sm text-red-500">{error}</p>42 ))}43 </div>44 )}45 />46
47 {/* Validation asynchrone avec debounce */}48 <form.Field49 name="email"50 validators={{51 onChangeAsyncDebounceMs: 500, // attend 500ms apres la derniere saisie52 onChangeAsync: async ({ value }) => {53 // Verification cote serveur54 const response = await fetch(55 `/api/check-email?email=${encodeURIComponent(value)}`56 );57 const { available } = await response.json();58
59 if (!available) {60 return 'Cette adresse email est deja utilisee';61 }62 return undefined;63 },64 }}65 children={(field) => (66 <div className="space-y-2">67 <label className="text-sm font-medium">Email</label>68 <input69 type="email"70 value={field.state.value}71 onChange={(e) => field.handleChange(e.target.value)}72 onBlur={field.handleBlur}73 className="w-full rounded-md border px-3 py-2"74 />75 {field.state.meta.isValidating && (76 <p className="text-sm text-muted-foreground">Verification en cours...</p>77 )}78 {field.state.meta.errors.map((error, i) => (79 <p key={i} className="text-sm text-red-500">{error}</p>80 ))}81 </div>82 )}83 />84
85 {/* Validation au blur uniquement */}86 <form.Field87 name="password"88 validators={{89 onBlur: ({ value }) => {90 if (value.length < 8) return 'Minimum 8 caracteres';91 if (!/[A-Z]/.test(value)) return 'Au moins une majuscule requise';92 if (!/[0-9]/.test(value)) return 'Au moins un chiffre requis';93 return undefined;94 },95 }}96 children={(field) => (97 <div className="space-y-2">98 <label className="text-sm font-medium">Mot de passe</label>99 <input100 type="password"101 value={field.state.value}102 onChange={(e) => field.handleChange(e.target.value)}103 onBlur={field.handleBlur}104 className="w-full rounded-md border px-3 py-2"105 />106 </div>107 )}108 />109 </form>110 );111}Integration avec Zod
1import { useForm } from '@tanstack/react-form';2import { zodValidator } from '@tanstack/zod-form-adapter';3import { z } from 'zod';4
5// Schema Zod reutilisable (partage front/back)6const contactSchema = z.object({7 name: z.string().min(2, 'Le nom doit contenir au moins 2 caracteres'),8 email: z.string().email('Adresse email invalide'),9 subject: z.enum(['support', 'commercial', 'partenariat'], {10 errorMap: () => ({ message: 'Veuillez selectionner un sujet' }),11 }),12 message: z13 .string()14 .min(10, 'Le message doit contenir au moins 10 caracteres')15 .max(2000, 'Le message ne peut pas depasser 2000 caracteres'),16 acceptTerms: z.literal(true, {17 errorMap: () => ({ message: 'Vous devez accepter les conditions' }),18 }),19});20
21type ContactFormData = z.infer<typeof contactSchema>;22
23export function ContactForm() {24 const form = useForm<ContactFormData>({25 defaultValues: {26 name: '',27 email: '',28 subject: 'support',29 message: '',30 acceptTerms: false as unknown as true,31 },32 validatorAdapter: zodValidator(),33 validators: {34 // Validation du formulaire entier avec Zod35 onChange: contactSchema,36 },37 onSubmit: async ({ value }) => {38 // value est de type ContactFormData39 await fetch('/api/contact', {40 method: 'POST',41 body: JSON.stringify(value),42 });43 },44 });45
46 return (47 <form48 onSubmit={(e) => { e.preventDefault(); form.handleSubmit(); }}49 className="space-y-6"50 >51 <form.Field52 name="name"53 children={(field) => (54 <div className="space-y-1">55 <label className="text-sm font-medium">Nom complet</label>56 <input57 value={field.state.value}58 onChange={(e) => field.handleChange(e.target.value)}59 className="w-full rounded-md border px-3 py-2"60 />61 {field.state.meta.errors.length > 0 && (62 <p className="text-sm text-red-500">63 {field.state.meta.errors.join(', ')}64 </p>65 )}66 </div>67 )}68 />69
70 <form.Field71 name="message"72 children={(field) => (73 <div className="space-y-1">74 <label className="text-sm font-medium">Message</label>75 <textarea76 value={field.state.value}77 onChange={(e) => field.handleChange(e.target.value)}78 rows={5}79 className="w-full rounded-md border px-3 py-2 resize-none"80 />81 <div className="flex justify-between text-xs text-muted-foreground">82 <span>83 {field.state.meta.errors.length > 084 ? field.state.meta.errors[0]85 : ''}86 </span>87 <span>{field.state.value.length} / 2000</span>88 </div>89 </div>90 )}91 />92
93 <form.Subscribe94 selector={(s) => [s.canSubmit, s.isSubmitting]}95 children={([canSubmit, isSubmitting]) => (96 <button97 type="submit"98 disabled={!canSubmit}99 className="w-full py-2 rounded-md bg-primary text-primary-foreground"100 >101 {isSubmitting ? 'Envoi en cours...' : 'Envoyer'}102 </button>103 )}104 />105 </form>106 );107}Validation cote serveur
1// --- Cote serveur : server action (Next.js App Router) ---2'use server';3
4import { z } from 'zod';5
6const orderSchema = z.object({7 productId: z.string().uuid(),8 quantity: z.number().int().positive().max(100),9 shippingAddress: z.object({10 street: z.string().min(5),11 city: z.string().min(2),12 postalCode: z.string().regex(/^\d{5}$/, 'Code postal invalide'),13 country: z.string().length(2),14 }),15});16
17export async function validateOrder(data: unknown) {18 const result = orderSchema.safeParse(data);19
20 if (!result.success) {21 // Retourne les erreurs formatees par champ22 return {23 success: false as const,24 errors: result.error.flatten().fieldErrors,25 };26 }27
28 // Validations metier cote serveur29 const product = await db.product.findUnique({30 where: { id: result.data.productId },31 });32
33 if (!product) {34 return {35 success: false as const,36 errors: { productId: ['Produit introuvable'] },37 };38 }39
40 if (product.stock < result.data.quantity) {41 return {42 success: false as const,43 errors: {44 quantity: [`Stock insuffisant. ${product.stock} unites disponibles.`],45 },46 };47 }48
49 return { success: true as const, data: result.data };50}51
52// --- Cote client : integration avec TanStack Form ---53'use client';54
55import { useForm } from '@tanstack/react-form';56import { validateOrder } from './actions';57
58export function OrderForm({ productId }: { productId: string }) {59 const form = useForm({60 defaultValues: {61 productId,62 quantity: 1,63 shippingAddress: {64 street: '',65 city: '',66 postalCode: '',67 country: 'FR',68 },69 },70 onSubmit: async ({ value }) => {71 const result = await validateOrder(value);72
73 if (!result.success) {74 // Appliquer les erreurs serveur aux champs correspondants75 Object.entries(result.errors).forEach(([field, messages]) => {76 form.setFieldMeta(field as any, (prev) => ({77 ...prev,78 errors: messages ?? [],79 }));80 });81 return;82 }83
84 // Succes : redirection ou notification85 window.location.href = `/orders/confirmation`;86 },87 });88
89 return (90 <form onSubmit={(e) => { e.preventDefault(); form.handleSubmit(); }}>91 <form.Field92 name="quantity"93 validators={{94 onChange: ({ value }) =>95 value < 1 ? 'Quantite minimum : 1' : undefined,96 }}97 children={(field) => (98 <div>99 <label>Quantite</label>100 <input101 type="number"102 min={1}103 max={100}104 value={field.state.value}105 onChange={(e) => field.handleChange(Number(e.target.value))}106 />107 {field.state.meta.errors.map((err, i) => (108 <p key={i} className="text-red-500 text-sm">{err}</p>109 ))}110 </div>111 )}112 />113 {/* ...autres champs d'adresse */}114 </form>115 );116}Comparaison : React Hook Form vs TanStack Form
- Ecosysteme mature et vaste
- Documentation exhaustive
- Excellente integration Zod/Yup
- Large communaute et ressources
- Re-renders au niveau du formulaire entier avec watch()
- API parfois verbose pour les cas complexes
- Validation asynchrone moins intuitive
- •Formulaires classiques (login, inscription)
- •Projets avec equipe habituee a RHF
- •Integration rapide avec des composants UI existants
- Re-renders isoles par champ modifie
- Validation sync, async et debounced native
- TypeScript-first avec inference complete
- Framework-agnostic (React, Vue, Solid, Angular)
- Ecosysteme plus jeune
- Moins de ressources communautaires
- API en evolution rapide
- •Formulaires complexes a haute performance
- •Formulaires multi-etapes avec validation serveur
- •Applications ou chaque milliseconde compte
React Hook Form | TanStack Form |
|---|---|
La reference etablie pour les formulaires React, basee sur des refs non controlees. | Architecture reactive basee sur @tanstack/store avec re-renders granulaires par champ. |
Avantages
| Avantages
|
Inconvenients
| Inconvenients
|
Cas d'usage
| Cas d'usage
|
Avez-vous vraiment besoin de Redux ou Zustand ?
@tanstack/store est un store reactif immutable qui pese environ 2 KB. Il constitue le moteur interne de TanStack Form et TanStack Router, gerant leurs mises a jour d'etat avec une granularite fine. Son API minimaliste le rend egalement utilisable comme solution standalone pour la gestion d'etat.
Creer un store reactif
1import { Store } from '@tanstack/store';2
3// Creation d'un store type-safe4interface CounterState {5 count: number;6 lastUpdated: Date | null;7}8
9const counterStore = new Store<CounterState>({10 count: 0,11 lastUpdated: null,12});13
14// Mise a jour immutable via setState15counterStore.setState((prev) => ({16 ...prev,17 count: prev.count + 1,18 lastUpdated: new Date(),19}));20
21// Ecouter les changements (framework-agnostic)22const unsubscribe = counterStore.subscribe(() => {23 console.log('Nouvel etat :', counterStore.state);24});25
26// Acceder a l'etat courant27console.log(counterStore.state.count); // 128
29// Se desabonner30unsubscribe();useStore : integration React
1'use client';2
3import { Store, useStore } from '@tanstack/react-store';4
5// -- Definition du store en dehors du composant --6interface AppNotification {7 id: string;8 message: string;9 type: 'info' | 'success' | 'error';10 timestamp: number;11}12
13interface NotificationState {14 notifications: AppNotification[];15 unreadCount: number;16}17
18const notificationStore = new Store<NotificationState>({19 notifications: [],20 unreadCount: 0,21});22
23// Actions : fonctions pures qui mettent a jour le store24export function addNotification(message: string, type: AppNotification['type']) {25 notificationStore.setState((prev) => {26 const notification: AppNotification = {27 id: crypto.randomUUID(),28 message,29 type,30 timestamp: Date.now(),31 };32 return {33 notifications: [notification, ...prev.notifications],34 unreadCount: prev.unreadCount + 1,35 };36 });37}38
39export function markAllAsRead() {40 notificationStore.setState((prev) => ({41 ...prev,42 unreadCount: 0,43 }));44}45
46export function clearNotifications() {47 notificationStore.setState(() => ({48 notifications: [],49 unreadCount: 0,50 }));51}52
53// -- Composant : badge de notifications --54// Ne re-rend que lorsque unreadCount change55export function NotificationBadge() {56 const unreadCount = useStore(notificationStore, (state) => state.unreadCount);57
58 if (unreadCount === 0) return null;59
60 return (61 <span className="inline-flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-500 rounded-full">62 {unreadCount > 99 ? '99+' : unreadCount}63 </span>64 );65}66
67// -- Composant : liste des notifications --68// Ne re-rend que lorsque le tableau notifications change69export function NotificationList() {70 const notifications = useStore(71 notificationStore,72 (state) => state.notifications73 );74
75 return (76 <div className="space-y-2 max-h-[400px] overflow-y-auto">77 {notifications.length === 0 ? (78 <p className="text-sm text-muted-foreground py-4 text-center">79 Aucune notification80 </p>81 ) : (82 notifications.map((notification) => (83 <div84 key={notification.id}85 className="p-3 rounded-lg border border-border/50 text-sm"86 >87 <p className="font-medium">{notification.message}</p>88 <p className="text-xs text-muted-foreground mt-1">89 {new Date(notification.timestamp).toLocaleTimeString('fr-FR')}90 </p>91 </div>92 ))93 )}94 </div>95 );96}Store : le moteur interne de Form et Router
@tanstack/store n'est pas seulement une librairie standalone. C'est le systeme reactif qui alimente TanStack Form (un store par champ) et TanStack Router (etat de navigation, search params). Comprendre Store permet de comprendre pourquoi ces outils sont si performants.
- -- TanStack Form : chaque champ est un micro-store independant. Modifier un champ ne notifie que son store, pas les autres
- -- TanStack Router : les search params et le loader data sont geres via des stores reactifs, permettant des mises a jour chirurgicales de l'UI
- -- Implication pratique : si vous comprenez Store, vous pouvez etendre Form et Router avec des comportements personnalises
Etat derive et selecteurs
1import { Store, useStore } from '@tanstack/react-store';2
3// -- Store e-commerce --4interface CartItem {5 id: string;6 name: string;7 price: number;8 quantity: number;9}10
11interface CartState {12 items: CartItem[];13 couponCode: string | null;14 couponDiscount: number; // pourcentage15}16
17const cartStore = new Store<CartState>({18 items: [],19 couponCode: null,20 couponDiscount: 0,21});22
23// Actions24export function addToCart(item: Omit<CartItem, 'quantity'>) {25 cartStore.setState((prev) => {26 const existing = prev.items.find((i) => i.id === item.id);27 if (existing) {28 return {29 ...prev,30 items: prev.items.map((i) =>31 i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i32 ),33 };34 }35 return {36 ...prev,37 items: [...prev.items, { ...item, quantity: 1 }],38 };39 });40}41
42export function applyCoupon(code: string, discount: number) {43 cartStore.setState((prev) => ({44 ...prev,45 couponCode: code,46 couponDiscount: discount,47 }));48}49
50// -- Selecteurs derives --51// Ces fonctions calculent des valeurs a partir de l'etat du store.52// useStore n'execute un re-render que si le resultat du selecteur change.53
54export function CartItemCount() {55 const count = useStore(cartStore, (state) =>56 state.items.reduce((sum, item) => sum + item.quantity, 0)57 );58
59 return <span className="text-sm font-medium">{count} articles</span>;60}61
62export function CartSubtotal() {63 const subtotal = useStore(cartStore, (state) =>64 state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)65 );66
67 return (68 <span className="font-semibold">69 {subtotal.toFixed(2)} EUR70 </span>71 );72}73
74export function CartTotal() {75 // Selecteur compose : sous-total avec reduction appliquee76 const total = useStore(cartStore, (state) => {77 const subtotal = state.items.reduce(78 (sum, item) => sum + item.price * item.quantity,79 080 );81 const discount = subtotal * (state.couponDiscount / 100);82 return subtotal - discount;83 });84
85 const hasDiscount = useStore(86 cartStore,87 (state) => state.couponDiscount > 088 );89
90 return (91 <div className="flex items-center gap-2">92 <span className="text-lg font-bold">{total.toFixed(2)} EUR</span>93 {hasDiscount && (94 <span className="text-xs text-green-600 font-medium">95 Reduction appliquee96 </span>97 )}98 </div>99 );100}Comparaison des solutions de gestion d'etat
- Ultra-leger (~2 KB)
- Zero re-renders inutiles
- Framework-agnostic
- Pas de provider/context necessaire
- Ecosysteme de middleware limite
- Pas de DevTools dediees
- Communaute plus restreinte
- •Etat local partage entre composants proches
- •Librairies framework-agnostic
- •Cas ou le poids du bundle est critique
- API extremement simple
- Middleware puissant (persist, devtools, immer)
- Excellente documentation
- Large adoption en production
- Specifique a React
- Legerement plus lourd (~3 KB)
- Selecteurs manuels pour eviter les re-renders
- •Etat global applicatif
- •Etat persiste (localStorage, sessionStorage)
- •Projets React avec besoin de middleware
- Aucune dependance externe
- Integre nativement a React
- Ideal pour les themes et preferences
- Re-rend tous les consommateurs a chaque changement
- Pas de selecteurs natifs
- Performance degradee pour l'etat qui change souvent
- •Theme, locale, authentification
- •Configuration qui change rarement
- •Props drilling sur 2-3 niveaux maximum
TanStack Store | Zustand | React Context |
|---|---|---|
Store immutable et reactif en ~2 KB, moteur interne de TanStack Form et Router. | Store minimaliste avec une API simple basee sur des hooks, middleware riche. | Solution native de React pour le partage d'etat via l'arbre de composants. |
Avantages
| Avantages
| Avantages
|
Inconvenients
| Inconvenients
| Inconvenients
|
Cas d'usage
| Cas d'usage
| Cas d'usage
|
Recommandation pratique
Le choix de la solution de gestion d'etat depend du contexte du projet, pas d'une preference technologique.
- -- Etat serveur (donnees API) : TanStack Query, toujours. Ne pas reinventer le cache
- -- Etat global complexe (panier, preferences, auth) : Zustand pour son ecosysteme mature
- -- Etat local partage (2-3 composants voisins) : TanStack Store ou un simple useState eleve
- -- Etat rarement modifie (theme, locale) : React Context reste le choix le plus simple
- -- Librairie framework-agnostic : TanStack Store est le seul choix viable a ~2 KB
Comment maîtriser le timing de vos animations ?
TanStack Pacer fournit des primitives de controle de debit pour React : debounce, throttle et rate limiting. Plutot que de reimplementer ces patterns a chaque projet avec des solutions ad hoc, Pacer offre des hooks type-safe, testables et optimises pour les scenarios courants comme la recherche en temps reel, les gestionnaires de scroll et les appels API externes.
Les trois hooks principaux
1import {2 useDebouncedCallback,3 useThrottledValue,4 useQueuedState,5} from '@tanstack/react-pacer';6
7// -- useDebouncedCallback --8// Execute le callback apres un delai d'inactivite9// Cas d'usage : recherche, auto-save, validation10const [debouncedSearch] = useDebouncedCallback(11 (query: string) => {12 fetchSearchResults(query);13 },14 { wait: 300 } // 300ms apres la derniere frappe15);16
17// -- useThrottledValue --18// Limite la frequence de mise a jour d'une valeur19// Cas d'usage : position de scroll, curseur, resize20const [throttledPosition] = useThrottledValue(21 mousePosition,22 { wait: 16 } // ~60fps maximum23);24
25// -- useQueuedState --26// File d'attente FIFO pour les mises a jour d'etat27// Cas d'usage : notifications sequentielles, animations chainées28const [currentItem, queue] = useQueuedState<Notification>({29 maxSize: 10,30 onProcess: (notification) => {31 showToast(notification);32 },33});Debouncing : recherche en temps reel
1'use client';2
3import { useState } from 'react';4import { useDebouncedCallback } from '@tanstack/react-pacer';5import { useQuery } from '@tanstack/react-query';6
7interface SearchResult {8 id: string;9 title: string;10 description: string;11 category: string;12}13
14export function SearchBar() {15 const [inputValue, setInputValue] = useState('');16 const [searchQuery, setSearchQuery] = useState('');17
18 // La recherche ne se declenche que 300ms apres la derniere frappe.19 // Chaque nouvelle frappe reinitialise le compteur.20 const [debouncedSetQuery] = useDebouncedCallback(21 (value: string) => {22 setSearchQuery(value);23 },24 { wait: 300 }25 );26
27 // TanStack Query ne fetch que lorsque searchQuery change28 const { data: results, isLoading, isFetching } = useQuery<SearchResult[]>({29 queryKey: ['search', searchQuery],30 queryFn: async () => {31 const response = await fetch(32 `/api/search?q=${encodeURIComponent(searchQuery)}`33 );34 if (!response.ok) throw new Error('Erreur de recherche');35 return response.json();36 },37 enabled: searchQuery.length >= 2, // pas de requete sous 2 caracteres38 staleTime: 1000 * 60, // cache 1 minute39 });40
41 return (42 <div className="relative w-full max-w-lg">43 <input44 type="search"45 value={inputValue}46 onChange={(e) => {47 setInputValue(e.target.value);48 debouncedSetQuery(e.target.value);49 }}50 placeholder="Rechercher..."51 className="w-full rounded-lg border border-border px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"52 />53
54 {/* Indicateur de chargement */}55 {isFetching && (56 <div className="absolute right-3 top-1/2 -translate-y-1/2">57 <div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />58 </div>59 )}60
61 {/* Resultats */}62 {results && results.length > 0 && (63 <div className="absolute top-full left-0 right-0 mt-2 rounded-lg border border-border bg-background shadow-lg z-50">64 {results.map((result) => (65 <div66 key={result.id}67 className="px-4 py-3 hover:bg-muted/50 cursor-pointer border-b border-border/30 last:border-0"68 >69 <p className="font-medium text-sm">{result.title}</p>70 <p className="text-xs text-muted-foreground mt-1">71 {result.description}72 </p>73 <span className="text-xs text-primary mt-1 inline-block">74 {result.category}75 </span>76 </div>77 ))}78 </div>79 )}80
81 {/* Aucun resultat */}82 {results && results.length === 0 && searchQuery.length >= 2 && (83 <div className="absolute top-full left-0 right-0 mt-2 rounded-lg border border-border bg-background p-4 text-center text-sm text-muted-foreground shadow-lg z-50">84 Aucun resultat pour « {searchQuery} »85 </div>86 )}87 </div>88 );89}Throttling : gestionnaires de scroll et resize
1'use client';2
3import { useState, useEffect, useRef } from 'react';4import { useThrottledValue } from '@tanstack/react-pacer';5
6// -- Header qui se masque au scroll vers le bas --7export function SmartHeader() {8 const [scrollY, setScrollY] = useState(0);9
10 // Limiter les mises a jour a 60fps maximum11 // Sans throttle, onScroll peut declencher 100+ events/seconde12 const [throttledScrollY] = useThrottledValue(scrollY, {13 wait: 16, // ~60fps (1000ms / 60 = 16.67ms)14 });15
16 const [isVisible, setIsVisible] = useState(true);17 const lastScrollY = useRef(0);18
19 useEffect(() => {20 const handleScroll = () => {21 setScrollY(window.scrollY);22 };23
24 window.addEventListener('scroll', handleScroll, { passive: true });25 return () => window.removeEventListener('scroll', handleScroll);26 }, []);27
28 // Reagir au scroll throttle, pas au scroll brut29 useEffect(() => {30 const direction = throttledScrollY > lastScrollY.current ? 'down' : 'up';31 const delta = Math.abs(throttledScrollY - lastScrollY.current);32
33 // Ne changer la visibilite que pour les mouvements significatifs34 if (delta > 10) {35 setIsVisible(direction === 'up' || throttledScrollY < 100);36 lastScrollY.current = throttledScrollY;37 }38 }, [throttledScrollY]);39
40 return (41 <header42 className={`fixed top-0 left-0 right-0 z-50 transition-transform duration-300 bg-background/95 backdrop-blur border-b ${43 isVisible ? 'translate-y-0' : '-translate-y-full'44 }`}45 >46 <nav className="max-w-7xl mx-auto px-6 py-4">47 {/* Contenu du header */}48 </nav>49 </header>50 );51}52
53// -- Indicateur de progression de lecture --54export function ReadingProgress() {55 const [scrollPercent, setScrollPercent] = useState(0);56
57 const [throttledPercent] = useThrottledValue(scrollPercent, {58 wait: 32, // ~30fps suffit pour une barre de progression59 });60
61 useEffect(() => {62 const updateProgress = () => {63 const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;64 const percent = scrollHeight > 0 ? (window.scrollY / scrollHeight) * 100 : 0;65 setScrollPercent(Math.round(percent));66 };67
68 window.addEventListener('scroll', updateProgress, { passive: true });69 return () => window.removeEventListener('scroll', updateProgress);70 }, []);71
72 return (73 <div className="fixed top-0 left-0 right-0 h-1 z-[60]">74 <div75 className="h-full bg-primary transition-[width] duration-150 ease-out"76 style={{ width: `${throttledPercent}%` }}77 />78 </div>79 );80}Rate limiting : appels API externes
1'use client';2
3import { useDebouncedCallback } from '@tanstack/react-pacer';4import { useState, useCallback } from 'react';5
6interface RateLimitConfig {7 maxRequests: number;8 windowMs: number;9}10
11// Hook personnalise : rate limiter pour API externes12function useRateLimitedCallback<T extends (...args: any[]) => Promise<any>>(13 callback: T,14 config: RateLimitConfig15) {16 const [requestTimestamps, setRequestTimestamps] = useState<number[]>([]);17 const [isLimited, setIsLimited] = useState(false);18
19 const execute = useCallback(20 async (...args: Parameters<T>) => {21 const now = Date.now();22 const windowStart = now - config.windowMs;23
24 // Nettoyer les timestamps hors fenetre25 const recentRequests = requestTimestamps.filter((ts) => ts > windowStart);26
27 if (recentRequests.length >= config.maxRequests) {28 setIsLimited(true);29 const oldestRequest = recentRequests[0];30 const waitTime = config.windowMs - (now - oldestRequest);31 console.warn(32 `Rate limit atteint. Prochaine requete disponible dans ${Math.ceil(waitTime / 1000)}s`33 );34 return null;35 }36
37 setIsLimited(false);38 setRequestTimestamps([...recentRequests, now]);39 return callback(...args);40 },41 [callback, config, requestTimestamps]42 );43
44 return { execute, isLimited };45}46
47// -- Utilisation avec une API de geocoding --48export function AddressAutocomplete() {49 const [suggestions, setSuggestions] = useState<string[]>([]);50
51 // Maximum 10 requetes par minute (limite API gratuite typique)52 const { execute: geocode, isLimited } = useRateLimitedCallback(53 async (query: string) => {54 const response = await fetch(55 `/api/geocode?q=${encodeURIComponent(query)}`56 );57 const data = await response.json();58 setSuggestions(data.suggestions);59 },60 { maxRequests: 10, windowMs: 60_000 }61 );62
63 // Combiner avec debounce : attendre 400ms + respecter la limite64 const [debouncedGeocode] = useDebouncedCallback(65 (value: string) => {66 if (value.length >= 3) {67 geocode(value);68 }69 },70 { wait: 400 }71 );72
73 return (74 <div className="space-y-2">75 <input76 onChange={(e) => debouncedGeocode(e.target.value)}77 placeholder="Saisissez une adresse..."78 className="w-full rounded-md border px-3 py-2"79 />80
81 {isLimited && (82 <p className="text-xs text-amber-600">83 Limite de requetes atteinte. Veuillez patienter quelques secondes.84 </p>85 )}86
87 {suggestions.length > 0 && (88 <ul className="rounded-md border divide-y">89 {suggestions.map((suggestion, i) => (90 <li key={i} className="px-3 py-2 text-sm hover:bg-muted/50 cursor-pointer">91 {suggestion}92 </li>93 ))}94 </ul>95 )}96 </div>97 );98}Batching et file d'attente
1'use client';2
3import { useQueuedState } from '@tanstack/react-pacer';4import { useState, useEffect } from 'react';5
6interface ToastNotification {7 id: string;8 message: string;9 type: 'success' | 'error' | 'info';10 duration: number;11}12
13// Systeme de notifications sequentielles :14// les toasts s'affichent un par un, pas tous en meme temps15export function ToastManager() {16 const [currentToast, setCurrentToast] = useState<ToastNotification | null>(null);17 const [isVisible, setIsVisible] = useState(false);18
19 const [, queue] = useQueuedState<ToastNotification>({20 maxSize: 20,21 onProcess: (toast) => {22 setCurrentToast(toast);23 setIsVisible(true);24 },25 });26
27 // Auto-dismiss apres la duree specifiee28 useEffect(() => {29 if (!currentToast) return;30
31 const timer = setTimeout(() => {32 setIsVisible(false);33 // Attendre la fin de l'animation avant de traiter le suivant34 setTimeout(() => {35 setCurrentToast(null);36 queue.next(); // Passer au toast suivant dans la file37 }, 300);38 }, currentToast.duration);39
40 return () => clearTimeout(timer);41 }, [currentToast, queue]);42
43 // API publique pour ajouter des toasts44 const addToast = (message: string, type: ToastNotification['type'] = 'info') => {45 queue.add({46 id: crypto.randomUUID(),47 message,48 type,49 duration: type === 'error' ? 5000 : 3000,50 });51 };52
53 return (54 <>55 {/* Zone d'affichage du toast courant */}56 {currentToast && (57 <div58 className={`fixed bottom-6 right-6 z-50 max-w-sm rounded-lg border p-4 shadow-lg transition-all duration-300 ${59 isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'60 } ${61 currentToast.type === 'success'62 ? 'bg-green-50 border-green-200 dark:bg-green-900/30 dark:border-green-800'63 : currentToast.type === 'error'64 ? 'bg-red-50 border-red-200 dark:bg-red-900/30 dark:border-red-800'65 : 'bg-background border-border'66 }`}67 >68 <p className="text-sm font-medium">{currentToast.message}</p>69 <p className="text-xs text-muted-foreground mt-1">70 {queue.size} notification(s) en attente71 </p>72 </div>73 )}74
75 {/* Boutons de demonstration */}76 <div className="flex gap-2">77 <button78 onClick={() => addToast('Operation reussie', 'success')}79 className="px-3 py-1.5 text-sm rounded-md bg-green-600 text-white"80 >81 Succes82 </button>83 <button84 onClick={() => addToast('Une erreur est survenue', 'error')}85 className="px-3 py-1.5 text-sm rounded-md bg-red-600 text-white"86 >87 Erreur88 </button>89 </div>90 </>91 );92}Guide de decision : debounce, throttle ou rate limit
Chaque strategie de controle de debit repond a un besoin specifique. Voici comment choisir la bonne approche.
- -- Debounce (attendre la fin de l'activite) : utiliser pour les saisies utilisateur ou l'action ne doit se declencher qu'apres un temps de pause. Exemples : recherche, auto-save, redimensionnement de fenetre. Delai typique : 200-500ms.
- -- Throttle (limiter la frequence) : utiliser quand l'action doit se declencher regulierement pendant l'activite. Exemples : position de scroll, mouvement de souris, progression. Delai typique : 16ms (60fps) a 100ms.
- -- Rate limit (quota maximum) : utiliser quand une API externe impose une limite de requetes. Exemples : API de geocoding, services tiers, endpoints sensibles. Configuration : X requetes par fenetre de temps.
- -- Queue/Batching (file d'attente) : utiliser quand les operations doivent s'executer sequentiellement ou par lot. Exemples : notifications, animations chainees, sync offline.
Comment débugger efficacement votre cache et vos queries ?
Les DevTools de l'ecosysteme TanStack sont des panneaux de debogage integres directement dans votre application. Ils permettent d'inspecter le cache de React Query, l'etat du routeur et la configuration des tables en temps reel. C'est l'equivalent d'un tableau de bord de production pour vos outils de developpement.
React Query DevTools : installation et configuration
1// Installation2// npm install @tanstack/react-query-devtools3
4// app/providers.tsx5'use client';6
7import { QueryClient, QueryClientProvider } from '@tanstack/react-query';8import { ReactQueryDevtools } from '@tanstack/react-query-devtools';9import { useState } from 'react';10
11export function Providers({ children }: { children: React.ReactNode }) {12 const [queryClient] = useState(13 () =>14 new QueryClient({15 defaultOptions: {16 queries: {17 staleTime: 1000 * 60, // 1 minute18 gcTime: 1000 * 60 * 5, // 5 minutes19 },20 },21 })22 );23
24 return (25 <QueryClientProvider client={queryClient}>26 {children}27 {/* Le panneau DevTools s'affiche en bas de l'ecran */}28 <ReactQueryDevtools29 initialIsOpen={false}30 buttonPosition="bottom-right"31 />32 </QueryClientProvider>33 );34}Lire le panneau React Query DevTools
1// Guide de lecture du panneau React Query DevTools2//3// Le panneau affiche toutes les queries en cache avec leur etat :4//5// -- ETATS DES QUERIES --6//7// [fresh] Vert La donnee est a jour, pas de refetch necessaire8// -> staleTime n'est pas encore ecoule9//10// [stale] Jaune La donnee est perimee, sera refetchee au prochain trigger11// -> staleTime est ecoule, attend un trigger (focus, mount, etc.)12//13// [fetching] Bleu Requete en cours d'execution14// -> Le loader tourne, la requete est partie au serveur15//16// [paused] Gris La requete est en pause (mode offline)17// -> Le navigateur est hors ligne, la requete reprendra18//19// [inactive] Gris Aucun composant n'observe cette query20// fonce -> La donnee reste en cache pendant gcTime21//22// -- INFORMATIONS PAR QUERY --23//24// Query Key : identifiant unique de la query (ex: ['users', { page: 1 }])25// Data : la derniere donnee recue, visualisable en JSON26// Observers : nombre de composants abonnes a cette query27// Last Updated: timestamp de la derniere mise a jour28// State : etat courant (fresh, stale, fetching, etc.)29//30// -- TIMERS IMPORTANTS --31//32// staleTime : duree pendant laquelle la donnee est consideree fraiche33// Defaut: 0 (toujours stale). Recommande: 30s-5min selon le cas34//35// gcTime : duree de retention en cache apres que tous les observers36// se sont desabonnes. Defaut: 5 minutes37//38// refetchInterval : intervalle de refetch automatique (si configure)39//40// -- ACTIONS DISPONIBLES --41//42// Refetch : Force un refetch immediat de la query43// Invalidate: Marque la query comme stale, refetch au prochain trigger44// Reset : Reinitialise la query a son etat initial45// Remove : Supprime la query du cacheRouter DevTools et Table DevTools
1// -- TanStack Router DevTools --2// Affiche l'etat complet du routeur : route active, params, search, loaders3
4// npm install @tanstack/router-devtools5import { TanStackRouterDevtools } from '@tanstack/router-devtools';6
7// Dans votre root route (TanStack Router uniquement)8export const Route = createRootRoute({9 component: () => (10 <>11 <Outlet />12 <TanStackRouterDevtools position="bottom-right" />13 </>14 ),15});16
17// Informations visibles :18// - Arbre des routes avec la route active19// - Search params et path params en temps reel20// - Etat des loaders (pending, success, error)21// - Matches de route et composants rendus22
23// -- TanStack Table DevTools --24// Inspecte l'etat interne d'une instance de table25
26// npm install @tanstack/react-table-devtools27import { ReactTableDevtools } from '@tanstack/react-table-devtools';28
29function DataTable() {30 const table = useReactTable({ /* ... */ });31
32 return (33 <div>34 <table>{/* rendu de la table */}</table>35 <ReactTableDevtools table={table} />36 </div>37 );38}39
40// Informations visibles :41// - Etat du tri, filtrage, pagination42// - Donnees brutes de chaque ligne43// - Colonnes visibles et leur configuration44// - Performance de rendu des cellulesPanneau DevTools unifie
1// Configuration unifiee pour un projet utilisant plusieurs outils TanStack2// Tous les DevTools sont accessibles depuis un seul point d'entree3
4'use client';5
6import { QueryClient, QueryClientProvider } from '@tanstack/react-query';7import { ReactQueryDevtools } from '@tanstack/react-query-devtools';8import { useState } from 'react';9
10// Panneau unifie avec chargement conditionnel11export function AppProviders({ children }: { children: React.ReactNode }) {12 const [queryClient] = useState(13 () =>14 new QueryClient({15 defaultOptions: {16 queries: {17 staleTime: 1000 * 30,18 gcTime: 1000 * 60 * 5,19 retry: 2,20 refetchOnWindowFocus: true,21 },22 mutations: {23 retry: 1,24 },25 },26 })27 );28
29 return (30 <QueryClientProvider client={queryClient}>31 {children}32
33 {/* React Query DevTools */}34 <ReactQueryDevtools35 initialIsOpen={false}36 buttonPosition="bottom-right"37 />38
39 {/* En production, les devtools sont automatiquement exclus40 du bundle par le tree-shaking grace au process.env.NODE_ENV */}41 </QueryClientProvider>42 );43}DevTools en production : activation conditionnelle
Les DevTools TanStack sont automatiquement exclues du bundle de production grace au tree-shaking. Mais il est parfois utile de les activer temporairement pour deboguer un probleme en production.
- -- Par defaut : les imports de devtools sont elimines en production par votre bundler (Webpack, Vite, Turbopack)
- -- Activation temporaire : utiliser un query param secret (
?debug=true) pour charger les devtools dynamiquement viaReact.lazy() - -- Securite : ne jamais exposer de donnees sensibles dans le cache. Les devtools affichent toutes les queries, y compris les tokens
- -- Performance : les devtools en mode production ajoutent un overhead. Les activer uniquement pour le diagnostic, puis les desactiver
Configuration complete avec chargement lazy
1'use client';2
3import { QueryClient, QueryClientProvider } from '@tanstack/react-query';4import { useState, lazy, Suspense } from 'react';5
6// Chargement lazy : les DevTools ne sont telecharges que si necessaire7const ReactQueryDevtools = lazy(() =>8 import('@tanstack/react-query-devtools').then((mod) => ({9 default: mod.ReactQueryDevtools,10 }))11);12
13// Detecter si les devtools doivent etre actives14function shouldShowDevtools(): boolean {15 if (typeof window === 'undefined') return false;16
17 // Mode developpement : toujours actif18 if (process.env.NODE_ENV === 'development') return true;19
20 // Mode production : actif uniquement via query param secret21 const params = new URLSearchParams(window.location.search);22 return params.get('debug') === process.env.NEXT_PUBLIC_DEBUG_TOKEN;23}24
25export function Providers({ children }: { children: React.ReactNode }) {26 const [queryClient] = useState(27 () =>28 new QueryClient({29 defaultOptions: {30 queries: {31 staleTime: 1000 * 60,32 gcTime: 1000 * 60 * 10,33 retry: (failureCount, error) => {34 // Ne pas retry les erreurs 4xx35 if (error instanceof Response && error.status < 500) return false;36 return failureCount < 3;37 },38 refetchOnWindowFocus: process.env.NODE_ENV === 'production',39 },40 },41 })42 );43
44 const showDevtools = shouldShowDevtools();45
46 return (47 <QueryClientProvider client={queryClient}>48 {children}49
50 {showDevtools && (51 <Suspense fallback={null}>52 <ReactQueryDevtools53 initialIsOpen={false}54 buttonPosition="bottom-right"55 />56 </Suspense>57 )}58 </QueryClientProvider>59 );60}Strategies de debogage
Diagnostiquer les problemes courants
Les DevTools permettent d'identifier rapidement les causes des problemes de performance et de comportement dans vos applications TanStack.
- -- Queries toujours stale : verifier que staleTime est configure. Valeur par defaut : 0 (toujours stale). Si toutes vos queries sont jaunes dans le panneau, augmenter staleTime a 30 secondes minimum pour les donnees qui ne changent pas souvent.
- -- Cycles de refetch infinis : verifier les queryKeys. Si une queryKey contient un objet recree a chaque render (ex: queryKey: ['users', {filter}]), chaque render cree une nouvelle query. Stabiliser les references avec useMemo.
- -- Cache inspector : dans le panneau, cliquer sur une query pour voir ses donnees en cache, le nombre d'observers et les timers. Si observers = 0 et la query reste en cache, le gcTime est peut-etre trop eleve.
- -- Mutations qui ne mettent pas a jour le cache : verifier que invalidateQueries() est appele dans onSuccess ou onSettled de la mutation. Les mutations ne mettent pas a jour le cache automatiquement.
- -- Donnees obsoletes apres navigation : verifier refetchOnMount et refetchOnWindowFocus. Si desactives, les donnees ne seront pas mises a jour lors du retour sur une page.
Comment intégrer TanStack Query avec le SSR Next.js ?
L'integration de TanStack Query avec le Server-Side Rendering est l'un des patterns les plus puissants de l'ecosysteme. Le principe : prefetcher les donnees cote serveur, les serialiser dans le HTML, puis les hydrater dans le cache client. L'utilisateur voit un contenu instantane sans flash de chargement, et le cache client est immediatement operationnel.
Server Components + prefetchQuery + dehydrate
1// app/users/page.tsx (Server Component)2import {3 dehydrate,4 HydrationBoundary,5 QueryClient,6} from '@tanstack/react-query';7import { UserList } from './user-list';8
9// Fonction de fetch reutilisable (serveur et client)10async function fetchUsers(page: number) {11 const response = await fetch(12 `${process.env.API_URL}/users?page=${page}&limit=20`,13 { next: { revalidate: 60 } } // ISR : revalider toutes les 60 secondes14 );15 if (!response.ok) throw new Error('Erreur chargement utilisateurs');16 return response.json();17}18
19export default async function UsersPage({20 searchParams,21}: {22 searchParams: Promise<{ page?: string }>;23}) {24 const params = await searchParams;25 const page = Number(params.page) || 1;26
27 // Creer un QueryClient dedie a cette requete serveur28 const queryClient = new QueryClient();29
30 // Prefetch : la donnee est mise en cache AVANT le rendu31 await queryClient.prefetchQuery({32 queryKey: ['users', { page }],33 queryFn: () => fetchUsers(page),34 });35
36 return (37 <HydrationBoundary state={dehydrate(queryClient)}>38 {/* UserList est un Client Component qui utilise useQuery */}39 {/* Le cache est deja rempli : pas de loading state initial */}40 <UserList initialPage={page} />41 </HydrationBoundary>42 );43}HydrationBoundary dans l'App Router
1// app/users/user-list.tsx (Client Component)2'use client';3
4import { useQuery, keepPreviousData } from '@tanstack/react-query';5import { useRouter, useSearchParams } from 'next/navigation';6
7interface User {8 id: string;9 name: string;10 email: string;11 role: string;12}13
14interface UsersResponse {15 users: User[];16 totalPages: number;17 currentPage: number;18}19
20export function UserList({ initialPage }: { initialPage: number }) {21 const router = useRouter();22 const searchParams = useSearchParams();23 const page = Number(searchParams.get('page')) || initialPage;24
25 // useQuery consomme les donnees prefetchees par le Server Component.26 // Au premier rendu, la donnee est deja en cache : pas de loading.27 const { data, isLoading, isFetching, isPlaceholderData } =28 useQuery<UsersResponse>({29 queryKey: ['users', { page }],30 queryFn: async () => {31 const response = await fetch(`/api/users?page=${page}&limit=20`);32 if (!response.ok) throw new Error('Erreur de chargement');33 return response.json();34 },35 placeholderData: keepPreviousData, // garder les donnees precedentes pendant le fetch36 staleTime: 1000 * 30, // considerer frais pendant 30 secondes37 });38
39 const goToPage = (newPage: number) => {40 router.push(`/users?page=${newPage}`);41 };42
43 if (isLoading) {44 return <div className="animate-pulse">Chargement...</div>;45 }46
47 return (48 <div className="space-y-4">49 {/* Indicateur de transition entre pages */}50 {isFetching && !isLoading && (51 <div className="h-1 bg-primary/20 rounded-full overflow-hidden">52 <div className="h-full bg-primary animate-pulse w-full" />53 </div>54 )}55
56 {/* Liste des utilisateurs */}57 <div className={`space-y-2 transition-opacity ${isPlaceholderData ? 'opacity-60' : 'opacity-100'}`}>58 {data?.users.map((user) => (59 <div key={user.id} className="p-4 rounded-lg border border-border/50">60 <p className="font-medium">{user.name}</p>61 <p className="text-sm text-muted-foreground">{user.email}</p>62 <span className="text-xs bg-muted px-2 py-0.5 rounded mt-1 inline-block">63 {user.role}64 </span>65 </div>66 ))}67 </div>68
69 {/* Pagination */}70 <div className="flex items-center justify-between">71 <button72 onClick={() => goToPage(page - 1)}73 disabled={page <= 1}74 className="px-4 py-2 rounded-md border text-sm disabled:opacity-50"75 >76 Precedent77 </button>78 <span className="text-sm text-muted-foreground">79 Page {data?.currentPage} sur {data?.totalPages}80 </span>81 <button82 onClick={() => goToPage(page + 1)}83 disabled={page >= (data?.totalPages ?? 1)}84 className="px-4 py-2 rounded-md border text-sm disabled:opacity-50"85 >86 Suivant87 </button>88 </div>89 </div>90 );91}Streaming avec Suspense
1// app/dashboard/page.tsx2// Pattern : prefetch multiple + streaming progressif3import {4 dehydrate,5 HydrationBoundary,6 QueryClient,7} from '@tanstack/react-query';8import { Suspense } from 'react';9import { DashboardStats } from './dashboard-stats';10import { RecentOrders } from './recent-orders';11import { UserActivity } from './user-activity';12
13// Fonctions de fetch separees14async function fetchStats() {15 const res = await fetch(`${process.env.API_URL}/dashboard/stats`);16 return res.json();17}18
19async function fetchRecentOrders() {20 const res = await fetch(`${process.env.API_URL}/orders/recent`);21 return res.json();22}23
24async function fetchUserActivity() {25 // Cette requete est lente (~2s)26 const res = await fetch(`${process.env.API_URL}/analytics/activity`);27 return res.json();28}29
30export default async function DashboardPage() {31 const queryClient = new QueryClient();32
33 // Prefetch les donnees rapides en parallele34 // Les donnees lentes seront streamees via Suspense35 await Promise.all([36 queryClient.prefetchQuery({37 queryKey: ['dashboard', 'stats'],38 queryFn: fetchStats,39 }),40 queryClient.prefetchQuery({41 queryKey: ['orders', 'recent'],42 queryFn: fetchRecentOrders,43 }),44 ]);45
46 // NE PAS attendre fetchUserActivity ici47 // Elle sera chargee en streaming cote client48
49 return (50 <HydrationBoundary state={dehydrate(queryClient)}>51 <div className="grid gap-6">52 {/* Rendu immediat : donnees deja prefetchees */}53 <DashboardStats />54 <RecentOrders />55
56 {/* Streaming : affiche un skeleton pendant le chargement */}57 <Suspense58 fallback={59 <div className="h-64 rounded-lg bg-muted animate-pulse" />60 }61 >62 <UserActivity />63 </Suspense>64 </div>65 </HydrationBoundary>66 );67}68
69// -- Composant streame --70// app/dashboard/user-activity.tsx71'use client';72
73import { useSuspenseQuery } from '@tanstack/react-query';74
75interface ActivityData {76 date: string;77 activeUsers: number;78 sessions: number;79}80
81export function UserActivity() {82 // useSuspenseQuery declenche le Suspense boundary parent83 // Le composant ne se rend que lorsque les donnees sont disponibles84 const { data } = useSuspenseQuery<ActivityData[]>({85 queryKey: ['analytics', 'activity'],86 queryFn: async () => {87 const response = await fetch('/api/analytics/activity');88 return response.json();89 },90 staleTime: 1000 * 60 * 5, // 5 minutes91 });92
93 return (94 <div className="rounded-lg border p-6">95 <h3 className="text-lg font-semibold mb-4">Activite utilisateurs</h3>96 <div className="space-y-2">97 {data.map((entry) => (98 <div key={entry.date} className="flex justify-between text-sm">99 <span>{new Date(entry.date).toLocaleDateString('fr-FR')}</span>100 <span className="font-medium">101 {entry.activeUsers} utilisateurs actifs102 </span>103 </div>104 ))}105 </div>106 </div>107 );108}TanStack Start : le framework full-stack TanStack
TanStack Start est un framework full-stack construit sur TanStack Router. Il integre nativement le SSR, le data fetching via les loaders du routeur, et une type-safety de bout en bout. C'est l'alternative TanStack a Next.js, optimisee pour l'ecosysteme TanStack.
- -- Routeur type-safe : chaque route a des types inferes pour ses params, search params et loader data. Les erreurs de typage sont detectees a la compilation
- -- Loaders integres : le data fetching est declare au niveau de la route, pas du composant. Le routeur orchestre le prefetching automatiquement
- -- Server Functions : equivalent des Server Actions de Next.js, avec une API type-safe et des validations integrees
- -- Deploiement flexible : compatible avec Vercel, Cloudflare Workers, Netlify, Node.js et Bun
- -- Etat de maturite : en beta active (debut 2026). A considerer pour les nouveaux projets si l'equipe est investie dans l'ecosysteme TanStack
Comparaison des approches SSR
- Ecosysteme mature et stable
- Documentation abondante
- Patterns bien etablis
- Compatible avec toutes les librairies existantes
- Data fetching couple au fichier page
- Hydratation manuelle du cache Query
- Pas de Server Components natifs
- Waterfall de requetes difficile a eviter
- •Projets existants en migration progressive
- •Equipes habituees au modele Pages Router
- •Applications avec contraintes de compatibilite
- Server Components natifs
- Streaming et Suspense integres
- prefetchQuery + dehydrate optimise
- Layouts imbriques et paralleles
- Courbe d'apprentissage plus elevee
- Certaines librairies pas encore compatibles
- Debugging plus complexe (server vs client)
- •Nouveaux projets Next.js
- •Applications avec fort besoin SEO
- •Projets necessitant du streaming
- Routeur 100% type-safe (params, search, loaders)
- Data fetching integre au routeur (loaders)
- Pas de frontiere server/client artificielle
- Demarrage le plus rapide pour un projet TanStack
- Ecosysteme encore jeune
- Moins de ressources communautaires
- Pas de deploiement Vercel optimise
- API en evolution
- •Projets greenfield avec ecosysteme TanStack complet
- •Applications necessitant un routeur type-safe
- •Equipes familiarisees avec TanStack
Pages Router | App Router | TanStack Start |
|---|---|---|
Architecture originale de Next.js avec getServerSideProps/getStaticProps pour le data fetching. | Architecture moderne de Next.js avec Server Components, Streaming et prefetching integre. | Framework full-stack de TanStack avec routeur type-safe, SSR integre et data fetching optimal. |
Avantages
| Avantages
| Avantages
|
Inconvenients
| Inconvenients
| Inconvenients
|
Cas d'usage
| Cas d'usage
| Cas d'usage
|
Comment architecturer une app TanStack en production ?
Construire une application avec l'ecosysteme TanStack en production demande une organisation rigoureuse. Cette section couvre les patterns d'architecture eprouves : separation des couches, query factories type-safe, hooks personnalises et strategies d'invalidation. L'objectif est une codebase maintenable par une equipe, pas seulement par son auteur initial.
Organisation du code : API layer, query factories, hooks
1// Structure recommandee pour un projet TanStack en production2//3// src/4// ├── api/ # Couche HTTP pure5// │ ├── client.ts # Instance fetch/axios configuree6// │ ├── users.api.ts # Endpoints utilisateurs7// │ ├── products.api.ts # Endpoints produits8// │ └── orders.api.ts # Endpoints commandes9// │10// ├── queries/ # Query factories (queryOptions)11// │ ├── users.queries.ts # Factories pour les queries utilisateurs12// │ ├── products.queries.ts # Factories pour les queries produits13// │ └── orders.queries.ts # Factories pour les queries commandes14// │15// ├── hooks/ # Hooks personnalises (logique metier)16// │ ├── use-users.ts # Hook combinant queries + mutations users17// │ ├── use-cart.ts # Hook panier (queries + store + mutations)18// │ └── use-auth.ts # Hook auth (queries + redirections)19// │20// └── components/ # Composants React21
22// -- 1. Couche API : fonctions HTTP pures, sans React --23// src/api/client.ts24const API_BASE = process.env.NEXT_PUBLIC_API_URL;25
26interface ApiError {27 status: number;28 message: string;29 code: string;30}31
32async function apiClient<T>(33 endpoint: string,34 options?: RequestInit35): Promise<T> {36 const response = await fetch(`${API_BASE}${endpoint}`, {37 ...options,38 headers: {39 'Content-Type': 'application/json',40 ...options?.headers,41 },42 });43
44 if (!response.ok) {45 const error: ApiError = await response.json().catch(() => ({46 status: response.status,47 message: response.statusText,48 code: 'UNKNOWN_ERROR',49 }));50 throw error;51 }52
53 return response.json();54}55
56export { apiClient };57
58// -- src/api/users.api.ts --59import { apiClient } from './client';60
61export interface User {62 id: string;63 name: string;64 email: string;65 role: 'admin' | 'editor' | 'viewer';66 createdAt: string;67}68
69export interface UsersListParams {70 page?: number;71 limit?: number;72 role?: User['role'];73 search?: string;74}75
76export interface PaginatedResponse<T> {77 data: T[];78 total: number;79 page: number;80 totalPages: number;81}82
83export const usersApi = {84 list: (params: UsersListParams = {}) =>85 apiClient<PaginatedResponse<User>>(86 `/users?${new URLSearchParams(params as Record<string, string>)}`87 ),88
89 getById: (id: string) =>90 apiClient<User>(`/users/${id}`),91
92 create: (data: Omit<User, 'id' | 'createdAt'>) =>93 apiClient<User>('/users', {94 method: 'POST',95 body: JSON.stringify(data),96 }),97
98 update: (id: string, data: Partial<User>) =>99 apiClient<User>(`/users/${id}`, {100 method: 'PATCH',101 body: JSON.stringify(data),102 }),103
104 delete: (id: string) =>105 apiClient<void>(`/users/${id}`, { method: 'DELETE' }),106};Query factories avec queryOptions()
1// src/queries/users.queries.ts2import { queryOptions } from '@tanstack/react-query';3import { usersApi, type UsersListParams } from '@/api/users.api';4
5// Query factory : centralise les queryKeys et queryFn6// queryOptions() infere automatiquement le type de retour7
8export const usersQueries = {9 // Cle racine pour toutes les queries utilisateurs10 all: () => queryOptions({11 queryKey: ['users'] as const,12 }),13
14 // Liste paginee avec filtres15 list: (params: UsersListParams = {}) => queryOptions({16 queryKey: ['users', 'list', params] as const,17 queryFn: () => usersApi.list(params),18 staleTime: 1000 * 30, // 30 secondes19 }),20
21 // Detail d'un utilisateur22 detail: (id: string) => queryOptions({23 queryKey: ['users', 'detail', id] as const,24 queryFn: () => usersApi.getById(id),25 staleTime: 1000 * 60, // 1 minute26 enabled: !!id, // ne fetch pas si id est vide27 }),28};29
30// -- Utilisation dans les composants --31
32import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';33
34// Dans un composant de liste35function UsersList({ role }: { role?: string }) {36 // Type automatiquement infere : PaginatedResponse<User>37 const { data, isLoading } = useQuery(38 usersQueries.list({ role: role as any, page: 1 })39 );40 // data.data est User[], data.total est number, etc.41}42
43// Dans un composant de detail44function UserProfile({ userId }: { userId: string }) {45 // Type automatiquement infere : User46 const { data: user } = useQuery(usersQueries.detail(userId));47 // user.name, user.email, etc. sont type-safe48}49
50// Invalidation precise51function useUpdateUser() {52 const queryClient = useQueryClient();53
54 return useMutation({55 mutationFn: ({ id, data }: { id: string; data: Partial<User> }) =>56 usersApi.update(id, data),57 onSuccess: (_, { id }) => {58 // Invalider uniquement la liste et le detail modifie59 queryClient.invalidateQueries({ queryKey: usersQueries.list().queryKey });60 queryClient.invalidateQueries({ queryKey: usersQueries.detail(id).queryKey });61 },62 });63}64
65// Prefetching dans un Server Component66// app/users/page.tsx67export default async function UsersPage() {68 const queryClient = new QueryClient();69
70 // Reutilise exactement la meme query factory71 await queryClient.prefetchQuery(usersQueries.list({ page: 1 }));72
73 return (74 <HydrationBoundary state={dehydrate(queryClient)}>75 <UsersList />76 </HydrationBoundary>77 );78}TypeScript generics avec queryOptions()
1// Patterns TypeScript avances pour des queries entierement type-safe2
3import { queryOptions, type QueryKey } from '@tanstack/react-query';4
5// -- Pattern 1 : Query factory generique --6// Reutilisable pour n'importe quelle entite CRUD7
8interface CrudApi<T, TCreate = Omit<T, 'id' | 'createdAt'>> {9 list: (params?: Record<string, unknown>) => Promise<{ data: T[]; total: number }>;10 getById: (id: string) => Promise<T>;11 create: (data: TCreate) => Promise<T>;12 update: (id: string, data: Partial<T>) => Promise<T>;13 delete: (id: string) => Promise<void>;14}15
16function createQueryFactory<T>(17 baseKey: string,18 api: CrudApi<T>19) {20 return {21 all: () => queryOptions({22 queryKey: [baseKey] as const,23 }),24
25 list: (params?: Record<string, unknown>) => queryOptions({26 queryKey: [baseKey, 'list', params] as const,27 queryFn: () => api.list(params),28 staleTime: 1000 * 30,29 }),30
31 detail: (id: string) => queryOptions({32 queryKey: [baseKey, 'detail', id] as const,33 queryFn: () => api.getById(id),34 staleTime: 1000 * 60,35 enabled: !!id,36 }),37 };38}39
40// Utilisation : toutes les factories ont les memes patterns41const usersQueries = createQueryFactory<User>('users', usersApi);42const productsQueries = createQueryFactory<Product>('products', productsApi);43const ordersQueries = createQueryFactory<Order>('orders', ordersApi);44
45// -- Pattern 2 : Hooks type-safe avec parametres generiques --46
47function useEntityList<T>(48 factory: ReturnType<typeof createQueryFactory<T>>,49 params?: Record<string, unknown>50) {51 const query = useQuery(factory.list(params));52
53 return {54 items: query.data?.data ?? [],55 total: query.data?.total ?? 0,56 isLoading: query.isLoading,57 error: query.error,58 refetch: query.refetch,59 };60}61
62// Utilisation : le type est automatiquement infere63function ProductPage() {64 const { items, total } = useEntityList(productsQueries);65 // items est Product[], total est number66 return <div>{items.map(p => <span key={p.id}>{p.name}</span>)}</div>;67}68
69// -- Pattern 3 : QueryKey typees pour l'invalidation --70
71type UsersQueryKey = ReturnType<typeof usersQueries.all>['queryKey']72 | ReturnType<typeof usersQueries.list>['queryKey']73 | ReturnType<typeof usersQueries.detail>['queryKey'];74
75// Fonction d'invalidation type-safe76function invalidateUsersQueries(77 queryClient: QueryClient,78 key: UsersQueryKey79) {80 return queryClient.invalidateQueries({ queryKey: key });81}Arbre de decision : quel outil TanStack pour quel besoin
L'ecosysteme TanStack couvre des domaines distincts. Chaque outil a un perimetre precis et ne devrait pas etre utilise en dehors de celui-ci.
- -- Donnees serveur (API REST, GraphQL) :
TanStack Query. Cache, revalidation, mutations, optimistic updates. C'est le premier outil a adopter. - -- Tableaux de donnees (tri, filtrage, pagination) :
TanStack Table. Headless, type-safe, combine avec Query pour le data fetching et Virtual pour les grands volumes. - -- Formulaires complexes (validation, multi-etapes) :
TanStack Form. Re-renders granulaires, validation async native, integration Zod. - -- Listes longues (+ de 100 elements) :
TanStack Virtual. Virtualisation verticale, horizontale, grille. A combiner avec Table pour les grands tableaux. - -- Routage type-safe (params, search, loaders) :
TanStack Router. Alternative a Next.js App Router avec type-safety de bout en bout. - -- Controle de debit (debounce, throttle) :
TanStack Pacer. Hooks reactifs pour limiter la frequence des operations couteuses. - -- Etat local reactif (partage entre composants) :
TanStack Store. Ultra-leger (~2 KB), utiliser uniquement si Zustand ou Context sont trop lourds.
Strategies d'organisation des queries
- Simple et rapide a ecrire
- Pas de fichier supplementaire
- Bon pour les prototypes
- Duplication des queryKeys
- queryFn dupliquee dans plusieurs composants
- Pas de reutilisabilite
- Refactoring couteux
- •Prototypes et preuves de concept
- •Composants avec une seule query unique
- Reutilisabilite maximale
- queryKeys centralises et coherents
- Type inference automatique
- Invalidation precise et previsible
- Fichiers supplementaires a creer
- Courbe d'apprentissage initiale
- Peut sembler excessif pour les petits projets
- •Applications de production
- •Equipes de plus de 2 developpeurs
- •Projets avec plus de 10 queries
- Encapsulation de la logique metier
- API simplifiee pour les consommateurs
- Testabilite amelioree
- Combine factory + logique applicative
- Couche d'abstraction supplementaire
- Risque de sur-ingenierie
- Plus de code a maintenir
- •Queries avec logique metier complexe
- •Compositions de queries et mutations
- •Logique partagee entre plusieurs pages
Inline (debutant) | Query Factories (recommande) | Custom Hooks (compose) |
|---|---|---|
Les queries sont declarees directement dans les composants, sans abstraction. | Les queries sont organisees en factories avec queryOptions(), reutilisables et type-safe. | Les query factories sont encapsulees dans des hooks personnalises avec logique metier. |
Avantages
| Avantages
| Avantages
|
Inconvenients
| Inconvenients
| Inconvenients
|
Cas d'usage
| Cas d'usage
| Cas d'usage
|
Checklist de mise en production
Avant de deployer une application basee sur l'ecosysteme TanStack, verifier systematiquement ces points critiques.
- -- Strategie de cache : chaque query a un staleTime et un gcTime adaptes a la frequence de changement des donnees. Les donnees rarement modifiees (profil, configuration) ont un staleTime eleve (5-10 min). Les donnees temps reel (notifications, chat) ont un staleTime bas (0-5s) ou un refetchInterval.
- -- Gestion des erreurs : chaque query et mutation a un gestionnaire d'erreur. Utiliser un QueryErrorBoundary global et des gestionnaires specifiques par composant. Configurer le retry : 3 tentatives pour les erreurs 5xx, 0 pour les erreurs 4xx.
- -- DevTools : configures en mode development avec chargement lazy. Token de debug pour activation en production. Verifier qu'aucune donnee sensible n'est exposee dans le cache (tokens, mots de passe).
- -- Types TypeScript : toutes les queries utilisent queryOptions() avec des types de retour explicites. Les queryKeys sont typees via "as const". L'invalidation utilise les queryKeys des factories, pas des chaines de caracteres.
- -- Prefetching SSR : les pages critiques prefetchent leurs donnees cote serveur via prefetchQuery + HydrationBoundary. Verifier que le premier rendu n'affiche pas de loading state.
- -- Bundle size : verifier que les DevTools ne sont pas inclus dans le bundle de production. Utiliser un import dynamique ou le tree-shaking natif. Cible : moins de 30 KB supplementaires pour l'ensemble de l'ecosysteme TanStack.
Recapitulatif de l'ecosysteme
1// Resume : quand utiliser chaque outil TanStack2//3// ┌─────────────────────────────────────────────────────────────────┐4// │ ECOSYSTEME TANSTACK │5// ├─────────────────┬───────────────────────────────────────────────┤6// │ TanStack Query │ Donnees serveur : cache, sync, mutations │7// │ │ -> Premier outil a adopter │8// │ │ -> Remplace useEffect + useState pour le │9// │ │ data fetching │10// ├─────────────────┼───────────────────────────────────────────────┤11// │ TanStack Table │ Tableaux headless : tri, filtre, pagination │12// │ │ -> Combine avec Query (data) et Virtual │13// │ │ (performance) │14// ├─────────────────┼───────────────────────────────────────────────┤15// │ TanStack Form │ Formulaires reactifs : validation granulaire │16// │ │ -> Alternative a React Hook Form quand la │17// │ │ performance par champ est critique │18// ├─────────────────┼───────────────────────────────────────────────┤19// │ TanStack Virtual│ Virtualisation : listes de 1000+ elements │20// │ │ -> Ne rend que les elements visibles │21// │ │ -> Vertical, horizontal, grille │22// ├─────────────────┼───────────────────────────────────────────────┤23// │ TanStack Router │ Routage type-safe : params, search, loaders │24// │ │ -> Alternative a Next.js Router │25// │ │ -> Type-safety de bout en bout │26// ├─────────────────┼───────────────────────────────────────────────┤27// │ TanStack Pacer │ Controle de debit : debounce, throttle │28// │ │ -> Hooks reactifs pour limiter les appels │29// ├─────────────────┼───────────────────────────────────────────────┤30// │ TanStack Store │ Store reactif : ~2 KB, immutable │31// │ │ -> Moteur interne de Form et Router │32// │ │ -> Standalone pour l'etat local partage │33// ├─────────────────┼───────────────────────────────────────────────┤34// │ TanStack Start │ Framework full-stack (beta) │35// │ │ -> Routeur + SSR + Server Functions │36// │ │ -> L'alternative TanStack a Next.js │37// └─────────────────┴───────────────────────────────────────────────┘38//39// Ordre d'adoption recommande :40// 1. Query (indispensable)41// 2. Table (si tableaux de donnees)42// 3. Virtual (si listes longues)43// 4. Form (si formulaires complexes)44// 5. Pacer (si controle de debit)45// 6. Store (si besoin specifique)46// 7. Router / Start (migration complete)Felicitations !
Vous avez termine ce guide.