Maxpaths
Fondamentaux·Section 1/14

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.

useEffect brut
Approche manuelle avec useState + useEffect + fetch. Aucune couche d'abstraction : chaque composant gere son propre loading, error et cache.
Avantages
  • Aucune dependance externe
  • Controle total sur le flux
  • Adapte pour un fetch ponctuel tres simple
Inconvenients
  • Aucun cache : refetch a chaque montage
  • Race conditions non gerees
  • Pas de deduplication des requetes identiques
  • Code boilerplate repetitif
  • Aucune revalidation en arriere-plan
Cas d'usage
  • Prototypes rapides
  • Composants isoles sans reutilisation
  • Projets sans server state significatif
TanStack Query
Solution complete de gestion du server state avec cache normalise, revalidation automatique, mutations avec invalidation, et devtools integres.
Avantages
  • Cache intelligent avec staleTime/gcTime
  • Revalidation automatique (focus, reconnect)
  • Deduplication des requetes
  • Mutations optimistes
  • DevTools puissants
  • TypeScript first-class
Inconvenients
  • Courbe d'apprentissage initiale
  • Dependance externe (~13kB gzip)
  • Overhead pour des cas tres simples
Cas d'usage
  • Applications avec CRUD complexe
  • Dashboards temps reel
  • Applications multi-pages avec donnees partagees
  • Tout projet avec server state significatif
SWR
Librairie de fetching par Vercel, axee sur la strategie stale-while-revalidate. Plus legere que TanStack Query, mais moins de fonctionnalites avancees.
Avantages
  • API minimale et intuitive
  • Bundle plus leger (~4kB gzip)
  • Revalidation automatique
  • Integration Next.js fluide
Inconvenients
  • Pas de mutations structurees
  • Pas d'invalidation granulaire
  • Pas d'infinite queries natif
  • DevTools limites
  • Moins de controle sur le cache
Cas d'usage
  • Applications Next.js simples
  • Projets privilegiant la legerete
  • Fetching read-only predominant

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.

Fondamentaux·Section 2/14

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.

app/providers.tsxtsx
1// Installation
2// npm install @tanstack/react-query
3// npm install @tanstack/react-query-devtools (optionnel, recommande)
4
5// app/providers.tsx
6'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 eviter
14 // le partage entre requetes serveur en SSR
15 const [queryClient] = useState(
16 () =>
17 new QueryClient({
18 defaultOptions: {
19 queries: {
20 // Les donnees sont considerees fraiches pendant 60 secondes
21 staleTime: 60 * 1000,
22 // Le cache est conserve 5 minutes apres la derniere utilisation
23 gcTime: 5 * 60 * 1000,
24 // Retry 3 fois avec backoff exponentiel par defaut
25 retry: 3,
26 // Refetch quand la fenetre reprend le focus
27 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.tsx
42import { 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.

components/user-list.tsxtsx
1import { useQuery } from '@tanstack/react-query';
2
3// Type pour nos donnees
4interface 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 isError
18 throw new Error(`Erreur HTTP ${response.status}: ${response.statusText}`);
19 }
20
21 return response.json();
22}
23
24// Composant utilisant useQuery
25export 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 echoue
31 error, // L'objet Error si isError === true
32 status, // 'pending' | 'error' | 'success'
33 isStale, // true si les donnees sont considerees obsoletes
34 isPlaceholderData, // true si on affiche des donnees placeholder
35 refetch, // Fonction pour forcer un refetch manuel
36 } = useQuery({
37 queryKey: ['users'],
38 queryFn: fetchUsers,
39 });
40
41 // Etat de chargement initial
42 if (isLoading) {
43 return <UserListSkeleton />;
44 }
45
46 // Gestion d'erreur
47 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 Reessayer
55 </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.

query-keys-examples.tstsx
1// Query keys : du simple au complexe
2
3// 1. String simple -- liste globale
4useQuery({ queryKey: ['users'], queryFn: fetchUsers });
5
6// 2. Avec un identifiant -- un element specifique
7useQuery({ queryKey: ['users', userId], queryFn: () => fetchUser(userId) });
8
9// 3. Avec un objet de filtres -- liste filtree
10useQuery({
11 queryKey: ['users', { role: 'admin', status: 'active' }],
12 queryFn: () => fetchUsers({ role: 'admin', status: 'active' }),
13});
14
15// 4. Cle hierarchique -- sous-ressource
16useQuery({
17 queryKey: ['users', userId, 'posts'],
18 queryFn: () => fetchUserPosts(userId),
19});
20
21// 5. Avec pagination
22useQuery({
23 queryKey: ['users', { page, limit, sortBy }],
24 queryFn: () => fetchUsers({ page, limit, sortBy }),
25});
26
27// IMPORTANT : la serialisation est deterministe
28// 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.
components/user-profile.tsxtsx
1// Fetch d'un utilisateur unique avec useQuery
2import { 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 invalide
30 enabled: userId > 0,
31 // Les donnees d'un profil restent fraiches 2 minutes
32 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 <img
43 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}
Modes de Rendu·Section 3/14

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.

staleTime
Duree pendant laquelle les donnees sont considerees fraiches. Tant que staleTime n&apos;est pas ecoule, aucune revalidation automatique ne sera declenchee (ni sur window focus, ni sur montage). Defaut : 0 (les donnees deviennent stale immediatement).
Avantages
  • 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
Inconvenients
  • Des donnees obsoletes peuvent etre affichees
  • Necessite de choisir la bonne valeur par cas
  • Trop eleve : donnees desynchronisees
  • Trop bas : requetes excessives
Cas d'usage
  • Donnees statiques : staleTime: Infinity
  • Profils utilisateur : staleTime: 5min
  • Listes de produits : staleTime: 30s
  • Donnees temps reel : staleTime: 0
gcTime
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&apos;expiration, les donnees sont supprimees. Defaut : 5 minutes.
Avantages
  • Retour instantane sur les pages deja visitees
  • Economise les requetes lors de navigation rapide
  • Permet le pattern stale-while-revalidate
  • Memoire liberee automatiquement
Inconvenients
  • Consomme de la memoire pour les donnees inactives
  • Trop eleve : consommation memoire excessive
  • Trop bas : perte du benefice cache
  • Non applicable aux donnees sensibles
Cas d'usage
  • Navigation frequente : gcTime: 10min
  • Grandes listes : gcTime: 2min
  • Donnees sensibles : gcTime: 0
  • Application SPA : gcTime: 30min

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.

revalidation-options.tsxtsx
1import { useQuery } from '@tanstack/react-query';
2
3// Configuration fine des options de revalidation
4export 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'utilisateur
11 // est sur un autre onglet
12 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 frequemment
16 refetchOnMount: true,
17
18 // Refetch quand la connexion revient (defaut: true)
19 // Essentiel pour les applications mobiles
20 refetchOnReconnect: true,
21
22 // Polling : refetch toutes les 30 secondes
23 // Ideal pour les dashboards temps reel
24 refetchInterval: 30_000,
25
26 // Le polling continue meme quand l'onglet n'est pas visible
27 // A utiliser avec parcimonie pour economiser les ressources
28 refetchIntervalInBackground: false,
29 });
30
31 return <StatsGrid stats={data} />;
32}
33
34// Exemple : donnees statiques sans revalidation
35export function AppConfig() {
36 const { data: config } = useQuery({
37 queryKey: ['config'],
38 queryFn: fetchAppConfig,
39 staleTime: Infinity, // Jamais stale
40 refetchOnWindowFocus: false, // Pas de refetch au focus
41 refetchOnMount: false, // Pas de refetch au montage
42 refetchOnReconnect: false, // Pas de refetch a la reconnexion
43 });
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.

conditional-queries.tsxtsx
1import { useQuery } from '@tanstack/react-query';
2import { useState } from 'react';
3
4// Exemple 1 : Recherche declenchee par l'utilisateur
5export 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 caracteres
13 enabled: debouncedTerm.length >= 3,
14 });
15
16 return (
17 <div>
18 <input
19 type="text"
20 value={searchTerm}
21 onChange={(e) => {
22 setSearchTerm(e.target.value);
23 // Debounce de 300ms avant de lancer la recherche
24 clearTimeout(window.__searchTimeout);
25 window.__searchTimeout = setTimeout(
26 () => setDebouncedTerm(e.target.value),
27 300
28 );
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 autre
44export function UserPosts({ userId }: { userId: number | null }) {
45 // Cette query ne se lance que quand userId est non-null
46 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.

select-transformation.tsxtsx
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'API
13async 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 noms
19export function UserNameList() {
20 const { data: names } = useQuery({
21 queryKey: ['users'],
22 queryFn: fetchUsers,
23 // select transforme les donnees AVANT de les exposer
24 // Si les donnees brutes n'ont pas change, select n'est pas recalcule
25 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 actifs
36export 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 mapping
47export function UserDirectory() {
48 const { data: directory } = useQuery({
49 queryKey: ['users'],
50 queryFn: fetchUsers,
51 select: (users) =>
52 users
53 .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 select

placeholderData 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.

placeholder-vs-initial.tsxtsx
1import { useQuery, keepPreviousData } from '@tanstack/react-query';
2import { useState } from 'react';
3
4// placeholderData : pagination fluide sans flash de loading
5export 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 precedente
12 // pendant le chargement de la nouvelle page
13 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 <button
27 onClick={() => setPage((p) => Math.max(1, p - 1))}
28 disabled={page === 1}
29 >
30 Precedent
31 </button>
32 <span>Page {page}</span>
33 <button
34 onClick={() => setPage((p) => p + 1)}
35 // Desactiver si on affiche des donnees placeholder
36 // (la page suivante n'existe peut-etre pas)
37 disabled={isPlaceholderData || !data?.hasMore}
38 >
39 Suivant
40 </button>
41 </div>
42 </div>
43 );
44}
45
46// initialData : hydratation depuis des donnees deja disponibles
47export 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 immediatement
55 initialData: prefetchedUser,
56 // Indiquer quand initialData a ete recupere pour le staleTime
57 initialDataUpdatedAt: Date.now() - 60_000, // il y a 1 minute
58 });
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.

infinite-scroll.tsxtsx
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 = pageParam
13 ? `/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 page
34 initialPageParam: null as string | null,
35 // Extraire le curseur pour la page suivante
36 getNextPageParam: (lastPage) =>
37 lastPage.hasMore ? lastPage.nextCursor : undefined,
38 });
39
40 // Charger automatiquement la page suivante quand le sentinel est visible
41 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.

parallel-dependent-queries.tsxtsx
1import { useQuery, useQueries } from '@tanstack/react-query';
2
3// Requetes paralleles automatiques
4// Ces deux queries se lancent en meme temps
5export 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 parallele
22 const isLoading = profileQuery.isLoading
23 || postsQuery.isLoading
24 || 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 paralleles
38export 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 premiere
54export function UserWithOrganization({ userId }: { userId: number }) {
55 // 1. D'abord, charger l'utilisateur
56 const { data: user } = useQuery({
57 queryKey: ['users', userId],
58 queryFn: () => fetchUser(userId),
59 });
60
61 // 2. Ensuite, charger l'organisation de l'utilisateur
62 // Cette query ne se lance PAS tant que user est undefined
63 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}
Modes de Rendu·Section 4/14

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.

create-user-form.tsxtsx
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 reussit
40 onSuccess: (newUser) => {
41 // Invalider la liste des utilisateurs pour forcer un refetch
42 queryClient.invalidateQueries({ queryKey: ['users'] });
43
44 // Notification de succes
45 toast.success(`Utilisateur ${newUser.name} cree avec succes`);
46
47 // Optionnel : pre-remplir le cache du detail utilisateur
48 queryClient.setQueryData(['users', newUser.id], newUser);
49 },
50
51 // Appele quand la mutation echoue
52 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 <button
85 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.

invalidation-patterns.tsxtsx
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 42
13// 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'autre
20await queryClient.invalidateQueries({
21 queryKey: ['users'],
22 exact: true,
23});
24
25// ---- FILTRAGE PAR PREDICAT ----
26// Invalider seulement les queries qui correspondent a une condition
27await 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 === false
33 );
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 liste
49 queryClient.invalidateQueries({ queryKey: ['users'] });
50 // Supprimer du cache la query detail de l'utilisateur supprime
51 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.

direct-cache-update.tsxtsx
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 utilisateur
18 // Pas de refetch necessaire : le serveur a retourne l'entite mise a jour
19 queryClient.setQueryData(['users', updatedUser.id], updatedUser);
20
21 // Mise a jour de la liste des utilisateurs en cache
22 queryClient.setQueryData<User[]>(['users'], (oldUsers) => {
23 if (!oldUsers) return [updatedUser];
24 return oldUsers.map((user) =>
25 user.id === updatedUser.id ? updatedUser : user
26 );
27 });
28 },
29 });
30}
31
32// Utilisation dans un composant
33export 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 appel
41 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.

optimistic-update.tsxtsx
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 parte
17 onMutate: async (todoId: number) => {
18 // 1a. Annuler les queries en cours pour eviter les conflits
19 // entre le refetch et notre mise a jour optimiste
20 await queryClient.cancelQueries({ queryKey: ['todos'] });
21
22 // 1b. Sauvegarder un snapshot de l'etat actuel pour le rollback
23 const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
24
25 // 1c. Appliquer la mise a jour optimiste dans le cache
26 queryClient.setQueryData<Todo[]>(['todos'], (old) =>
27 old?.map((todo) =>
28 todo.id === todoId
29 ? { ...todo, completed: !todo.completed }
30 : todo
31 )
32 );
33
34 // 1d. Retourner le snapshot pour le rollback
35 return { previousTodos };
36 },
37
38 // ETAPE 2 : En cas d'erreur, rollback
39 onError: (_error, _todoId, context) => {
40 // Restaurer le snapshot sauvegarde dans onMutate
41 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 serveur
48 onSettled: () => {
49 // Invalider pour s'assurer que le cache est en phase avec le serveur
50 queryClient.invalidateQueries({ queryKey: ['todos'] });
51 },
52 });
53}
54
55// Utilisation : l'interface reagit instantanement
56export 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 <button
62 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.

Modes de Rendu·Section 5/14

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.

query-options.tstsx
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 partout
13export 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 : User
40export 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 options
46export function UserLink({ userId }: { userId: number }) {
47 const queryClient = useQueryClient();
48
49 const handleMouseEnter = () => {
50 // Prefetch au survol : les options sont identiques
51 queryClient.prefetchQuery(userQueryOptions(userId));
52 };
53
54 return (
55 <a href={`/users/${userId}`} onMouseEnter={handleMouseEnter}>
56 Voir le profil
57 </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.

lib/queries/users.tstsx
1// lib/queries/users.ts - Query Key Factory
2
3// Le factory centralise toutes les keys d'un domaine
4export const userKeys = {
5 // Racine du domaine
6 all: ['users'] as const,
7
8 // Listes avec filtres
9 lists: () => [...userKeys.all, 'list'] as const,
10 list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
11
12 // Details individuels
13 details: () => [...userKeys.all, 'detail'] as const,
14 detail: (id: number) => [...userKeys.details(), id] as const,
15
16 // Sous-ressources
17 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 domaine
22export 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 queries
33useQuery({
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 invalidations
44// Invalider TOUTES les listes utilisateur (quel que soit le filtre)
45queryClient.invalidateQueries({ queryKey: userKeys.lists() });
46
47// Invalider TOUT ce qui concerne le user 42
48queryClient.invalidateQueries({ queryKey: userKeys.detail(42) });
49
50// Invalider absolument toutes les queries utilisateur
51queryClient.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.

hooks/use-users.tstsx
1// hooks/use-users.ts
2
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 Supprimer
87 </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).

prefetching-patterns.tsxtsx
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 lien
10export function UserListItem({ user }: { user: User }) {
11 const queryClient = useQueryClient();
12
13 return (
14 <Link
15 href={`/users/${user.id}`}
16 onMouseEnter={() => {
17 // Prefetch les donnees du profil quand l'utilisateur survole le lien
18 queryClient.prefetchQuery(userQueryOptions(user.id));
19 }}
20 onFocus={() => {
21 // Egalement au focus pour l'accessibilite clavier
22 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 + posts
33 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 composant
45export function UserPageLayout({ userId }: { userId: number }) {
46 // Les donnees sont prefetchees des que ce composant est monte
47 // Utile pour prefetcher les donnees de la prochaine section visible
48 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.

error-handling-global.tstsx
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 query
10 // 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 mutation
19 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 404
30 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 defaut
41 throwOnError: false, // Ne pas propager aux Error Boundaries par defaut
42 },
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 typees
63async 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).

Modes de Rendu·Section 6/14

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.

routes-definition.tsxtsx
1// ---- FILE-BASED ROUTING ----
2// Structure de fichiers (convention TanStack Router)
3//
4// src/routes/
5// |-- __root.tsx # Layout racine
6// |-- index.tsx # /
7// |-- about.tsx # /about
8// |-- users/
9// | |-- index.tsx # /users
10// | |-- $userId.tsx # /users/:userId (parametre dynamique)
11// | |-- $userId/
12// | |-- posts.tsx # /users/:userId/posts
13// |-- _authenticated/ # Layout group (pas dans l'URL)
14// | |-- dashboard.tsx # /dashboard (avec layout authentifie)
15// | |-- settings.tsx # /settings
16
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 routes
31 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 correspond
38 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 pur
47import { 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 routes
70const routeTree = rootRoute.addChildren([
71 indexRoute,
72 usersRoute.addChildren([userRoute]),
73]);
74
75// Creation du routeur avec type-safety complete
76const router = createRouter({ routeTree });
77
78// Declaration du type pour l'auto-completion globale
79declare 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().

src/routes/users/$userId.tsxtsx
1// src/routes/users/$userId.tsx
2import { createFileRoute } from '@tanstack/react-router';
3
4// Le loader s'execute AVANT que le composant ne soit rendu
5export const Route = createFileRoute('/users/$userId')({
6 // Le loader recoit les params de route types automatiquement
7 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 parallele
12 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 disponibles
21 component: UserDetailPage,
22
23 // Affiche pendant le chargement du loader
24 pendingComponent: () => <UserDetailSkeleton />,
25
26 // Temps minimum d'affichage du pending pour eviter les flash
27 pendingMinMs: 200,
28
29 // Affiche si le loader echoue
30 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 loader
39 // 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.

router-query-integration.tsxtsx
1// src/routes/users/$userId.tsx
2import { 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 Query
8 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 cache
13 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 loader
27 // 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 focus
33 // - refetch selon staleTime
34 // - invalidation apres mutation
35 // Tout cela sans aucun code supplementaire
36
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 routeur
61 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.

search-params-validation.tsxtsx
1// src/routes/users/index.tsx
2import { createFileRoute } from '@tanstack/react-router';
3import { z } from 'zod';
4
5// Schema de validation des search params
6const 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 schema
16type UsersSearch = z.infer<typeof usersSearchSchema>;
17
18export const Route = createFileRoute('/users/')({
19 // Le schema valide et parse les search params
20 validateSearch: usersSearchSchema,
21
22 component: UsersListPage,
23});
24
25function UsersListPage() {
26 // useSearch() retourne le type exact du schema
27 // Chaque propriete est garantie d'avoir le bon type
28 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 <select
36 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 <input
50 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 <button
68 onClick={() => navigate({ search: (prev) => ({ ...prev, page: page - 1 }) })}
69 disabled={page === 1}
70 >
71 Precedent
72 </button>
73 <span>Page {page}</span>
74 <button
75 onClick={() => navigate({ search: (prev) => ({ ...prev, page: page + 1 }) })}
76 >
77 Suivant
78 </button>
79 </div>
80 </div>
81 );
82}
83
84// ---- Link type-safe avec search params ----
85// TypeScript verifie que les search params sont valides
86<Link
87 to="/users"
88 search={{ page: 1, role: 'admin', sortBy: 'name', sortOrder: 'asc', limit: 20 }}
89>
90 Voir les administrateurs
91</Link>
React Router
Le routeur React historique, le plus utilise. API declarative, comunaute massive, integration large. v7 introduit les loaders et actions inspires de Remix.
Avantages
  • Ecosysteme et communaute massifs
  • Documentation mature et complete
  • Loaders et actions (v7+)
  • Compatible avec tous les meta-frameworks
Inconvenients
  • Type-safety limitee (paths en string)
  • Search params non types nativement
  • Pas d&apos;auto-completion sur les routes
  • Migration complexe entre versions majeures
Cas d'usage
  • Applications existantes avec React Router
  • Projets privilegiant la stabilite ecosysteme
  • Equipes familières avec l&apos;API
TanStack Router
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.
Avantages
  • 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
Inconvenients
  • Ecosysteme plus jeune
  • Communaute plus petite
  • Courbe d&apos;apprentissage TypeScript exigeante
  • Non compatible avec Next.js (SPA seulement)
Cas d'usage
  • Nouvelles SPA avec TypeScript strict
  • Applications avec TanStack Query
  • Projets valorisant la type-safety maximale
Next.js Router
Routeur file-based integre a Next.js. App Router avec Server Components, Server Actions, layouts imbriques et streaming. Optimise pour le rendu serveur.
Avantages
  • Server Components natifs
  • Streaming et Suspense integres
  • Layouts imbriques puissants
  • SEO et performance SSR optimaux
  • Pas de configuration routing
Inconvenients
  • Couple a Next.js (pas portable)
  • Type-safety des params limitee
  • Search params non valides nativement
  • Complexite Server/Client boundary
Cas d'usage
  • Applications full-stack avec SSR
  • Sites avec fort besoin SEO
  • Projets utilisant l&apos;ecosysteme Vercel
Optimisations·Section 7/14

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

basic-table.tsxtsx
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 complet
17const 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 table
55function 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 <th
69 key={header.id}
70 className="px-4 py-3 text-left text-sm font-semibold"
71 >
72 {header.isPlaceholder
73 ? null
74 : 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.

sorting.tsxtsx
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 Table
11 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 tri
21 enableMultiSort: true, // Shift+clic pour trier sur plusieurs colonnes
22 enableSortingRemoval: true, // 3eme clic retire le tri
23 maxMultiSortColCount: 3, // Maximum 3 colonnes de tri simultanees
24 });
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 natif
38 },
39 {
40 accessorKey: 'createdAt',
41 header: 'Date',
42 sortingFn: 'datetime', // Tri chronologique natif
43 },
44 {
45 accessorKey: 'priority',
46 header: 'Priorite',
47 // Tri personnalise : haute > moyenne > basse
48 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

filtering.tsxtsx
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 colonnes
25 globalFilterFn: 'includesString',
26 });
27
28 return (
29 <div className="space-y-4">
30 {/* Recherche globale */}
31 <input
32 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 <input
42 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} lignes
55 </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

pagination.tsxtsx
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 <button
32 onClick={() => table.firstPage()}
33 disabled={!table.getCanPreviousPage()}
34 className="px-3 py-1 rounded border disabled:opacity-50"
35 >
36 Debut
37 </button>
38 <button
39 onClick={() => table.previousPage()}
40 disabled={!table.getCanPreviousPage()}
41 className="px-3 py-1 rounded border disabled:opacity-50"
42 >
43 Precedent
44 </button>
45 <button
46 onClick={() => table.nextPage()}
47 disabled={!table.getCanNextPage()}
48 className="px-3 py-1 rounded border disabled:opacity-50"
49 >
50 Suivant
51 </button>
52 <button
53 onClick={() => table.lastPage()}
54 disabled={!table.getCanNextPage()}
55 className="px-3 py-1 rounded border disabled:opacity-50"
56 >
57 Fin
58 </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 <select
67 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 page
74 </option>
75 ))}
76 </select>
77 </div>
78 </div>
79 </div>
80 );
81}

Selection, visibilite et redimensionnement

advanced-features.tsxtsx
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 lignes
11 const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
12
13 // -- Visibilite des colonnes
14 const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
15 email: true,
16 role: true,
17 createdAt: false, // Colonne masquee par defaut
18 });
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 selection
36 const selectionColumn: ColumnDef<User> = {
37 id: 'select',
38 header: ({ table }) => (
39 <input
40 type="checkbox"
41 checked={table.getIsAllRowsSelected()}
42 onChange={table.getToggleAllRowsSelectedHandler()}
43 />
44 ),
45 cell: ({ row }) => (
46 <input
47 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 colonnes
57 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 <input
62 type="checkbox"
63 checked={column.getIsVisible()}
64 onChange={column.getToggleVisibilityHandler()}
65 />
66 {column.id}
67 </label>
68 ))}
69 </div>
70 );
71
72 // Indicateur de selection
73 const selectedCount = Object.keys(rowSelection).length;
74 // selectedCount donne le nombre de lignes selectionnees
75
76 return { table, ColumnToggle, selectedCount };
77}

Groupement et agregation

grouping-aggregation.tsxtsx
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 groupe
20 aggregationFn: 'count',
21 aggregatedCell: ({ getValue }) =>
22 `${getValue()} employe(s)`,
23 },
24 {
25 accessorKey: 'salary',
26 header: 'Salaire',
27 // Agregation : moyenne des salaires par departement
28 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 groupes
45 // {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/collapse
51 // <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'agregation
57 // flexRender(cell.column.columnDef.aggregatedCell, cell.getContext())
58 // ) : cell.getIsPlaceholder() ? null : (
59 // // Cellule normale
60 // 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 valeurs
71 // 'min' - Valeur minimale
72 // 'max' - Valeur maximale
73 // 'extent' - [min, max]
74 // 'mean' - Moyenne
75 // 'median' - Mediane
76 // 'unique' - Valeurs uniques
77 // 'uniqueCount' - Nombre de valeurs uniques
78 // 'count' - Nombre d'elements
79}
TanStack Table
Librairie headless, zero markup, logique pure avec adaptateurs multi-framework
Avantages
  • 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
Inconvenients
  • Plus de code a ecrire pour le rendu
  • Pas de composants pre-faits
  • Courbe d'apprentissage pour les features avancees
Cas d'usage
  • Projets avec design system personnalise
  • Dashboards et back-offices sur mesure
  • Applications multi-framework
AG Grid
Datagrid complet avec rendu integre, orientee enterprise avec licence commerciale
Avantages
  • Fonctionnalites enterprise tres completes
  • Rendu integre et performant (canvas)
  • Excel export, pivot tables, charts integres
Inconvenients
  • Licence payante pour les features avancees
  • Bundle lourd (~300 KB min)
  • Style difficile a personnaliser en profondeur
  • Lock-in sur l'API AG Grid
Cas d'usage
  • Applications financieres et trading
  • ERP et outils enterprise lourds
  • Besoin d'export Excel natif
Material UI DataGrid
Datagrid integree a l'ecosysteme Material UI avec rendu Material Design
Avantages
  • Integration native Material UI
  • Bonne documentation
  • Version communautaire gratuite
Inconvenients
  • 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
Cas d'usage
  • Projets deja bases sur Material UI
  • Prototypage rapide avec Material Design
  • Applications internes sans exigence de design

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
Optimisations·Section 8/14

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

virtual-lists.tsxtsx
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 element
12 overscan: 5, // elements pre-rendus hors viewport
13 });
14
15 return (
16 <div
17 ref={parentRef}
18 className="h-[500px] overflow-auto"
19 >
20 <div
21 style={{
22 height: `${virtualizer.getTotalSize()}px`,
23 width: '100%',
24 position: 'relative',
25 }}
26 >
27 {virtualizer.getVirtualItems().map((virtualItem) => (
28 <div
29 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 element
56 overscan: 3,
57 });
58
59 return (
60 <div ref={parentRef} className="overflow-x-auto">
61 <div
62 style={{
63 width: `${virtualizer.getTotalSize()}px`,
64 height: '200px',
65 position: 'relative',
66 }}
67 >
68 {virtualizer.getVirtualItems().map((virtualItem) => (
69 <div
70 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 <div
111 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 <div
124 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

sizing-strategies.tsxtsx
1import { useVirtualizer } from '@tanstack/react-virtual';
2import { useRef, useCallback } from 'react';
3
4// -- Taille fixe : tous les elements ont la meme hauteur
5const fixedVirtualizer = useVirtualizer({
6 count: 10000,
7 getScrollElement: () => parentRef.current,
8 estimateSize: () => 48, // valeur exacte, pas d'estimation
9});
10
11// -- Taille variable : hauteur connue a l'avance par element
12const variableVirtualizer = useVirtualizer({
13 count: messages.length,
14 getScrollElement: () => parentRef.current,
15 estimateSize: (index) => {
16 // Hauteur differente selon le type de message
17 const message = messages[index];
18 if (message.type === 'image') return 300;
19 if (message.type === 'code') return 200;
20 return 64; // message texte standard
21 },
22});
23
24// -- Taille dynamique mesuree : hauteur inconnue, mesuree au rendu
25function 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 initiale
32 measureElement: (element) => {
33 // Mesure reelle du DOM apres le rendu
34 return element.getBoundingClientRect().height;
35 },
36 });
37
38 return (
39 <div ref={parentRef} className="h-[600px] overflow-auto">
40 <div
41 style={{
42 height: `${virtualizer.getTotalSize()}px`,
43 position: 'relative',
44 }}
45 >
46 {virtualizer.getVirtualItems().map((virtualItem) => (
47 <div
48 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

virtualized-table.tsxtsx
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 <th
58 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 <td
71 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 <tr
78 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

product-catalog-virtual.tsxtsx
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 demonstration
15function 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')} produits
42 </h2>
43 <span className="text-sm text-muted-foreground">
44 {virtualizer.getVirtualItems().length} elements rendus dans le DOM
45 </span>
46 </div>
47
48 <div
49 ref={parentRef}
50 className="h-[500px] overflow-auto rounded-xl border border-border/50"
51 >
52 <div
53 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 <div
63 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)} EUR
82 </span>
83 <span
84 className={`text-xs px-2 py-0.5 rounded-full ${
85 product.inStock
86 ? '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
Optimisations·Section 9/14

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

profile-form.tsxtsx
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 : UserProfile
24 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 <form
38 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.Field
47 name="firstName"
48 children={(field) => (
49 <div className="space-y-2">
50 <label htmlFor={field.name} className="text-sm font-medium">
51 Prenom
52 </label>
53 <input
54 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.Field
70 name="email"
71 children={(field) => (
72 <div className="space-y-2">
73 <label htmlFor={field.name} className="text-sm font-medium">
74 Email
75 </label>
76 <input
77 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.Field
89 name="role"
90 children={(field) => (
91 <div className="space-y-2">
92 <label htmlFor={field.name} className="text-sm font-medium">
93 Role
94 </label>
95 <select
96 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.Subscribe
110 selector={(state) => [state.canSubmit, state.isSubmitting]}
111 children={([canSubmit, isSubmitting]) => (
112 <button
113 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

registration-validation.tsxtsx
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.Field
19 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&apos;utilisateur</label>
34 <input
35 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.Field
49 name="email"
50 validators={{
51 onChangeAsyncDebounceMs: 500, // attend 500ms apres la derniere saisie
52 onChangeAsync: async ({ value }) => {
53 // Verification cote serveur
54 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 <input
69 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.Field
87 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 <input
100 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

zod-integration.tsxtsx
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: z
13 .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 Zod
35 onChange: contactSchema,
36 },
37 onSubmit: async ({ value }) => {
38 // value est de type ContactFormData
39 await fetch('/api/contact', {
40 method: 'POST',
41 body: JSON.stringify(value),
42 });
43 },
44 });
45
46 return (
47 <form
48 onSubmit={(e) => { e.preventDefault(); form.handleSubmit(); }}
49 className="space-y-6"
50 >
51 <form.Field
52 name="name"
53 children={(field) => (
54 <div className="space-y-1">
55 <label className="text-sm font-medium">Nom complet</label>
56 <input
57 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.Field
71 name="message"
72 children={(field) => (
73 <div className="space-y-1">
74 <label className="text-sm font-medium">Message</label>
75 <textarea
76 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 > 0
84 ? 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.Subscribe
94 selector={(s) => [s.canSubmit, s.isSubmitting]}
95 children={([canSubmit, isSubmitting]) => (
96 <button
97 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

server-validation.tsxtsx
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 champ
22 return {
23 success: false as const,
24 errors: result.error.flatten().fieldErrors,
25 };
26 }
27
28 // Validations metier cote serveur
29 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 correspondants
75 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 notification
85 window.location.href = `/orders/confirmation`;
86 },
87 });
88
89 return (
90 <form onSubmit={(e) => { e.preventDefault(); form.handleSubmit(); }}>
91 <form.Field
92 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 <input
101 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

React Hook Form
La reference etablie pour les formulaires React, basee sur des refs non controlees.
Avantages
  • Ecosysteme mature et vaste
  • Documentation exhaustive
  • Excellente integration Zod/Yup
  • Large communaute et ressources
Inconvenients
  • Re-renders au niveau du formulaire entier avec watch()
  • API parfois verbose pour les cas complexes
  • Validation asynchrone moins intuitive
Cas d'usage
  • Formulaires classiques (login, inscription)
  • Projets avec equipe habituee a RHF
  • Integration rapide avec des composants UI existants
TanStack Form
Architecture reactive basee sur @tanstack/store avec re-renders granulaires par champ.
Avantages
  • Re-renders isoles par champ modifie
  • Validation sync, async et debounced native
  • TypeScript-first avec inference complete
  • Framework-agnostic (React, Vue, Solid, Angular)
Inconvenients
  • Ecosysteme plus jeune
  • Moins de ressources communautaires
  • API en evolution rapide
Cas d'usage
  • Formulaires complexes a haute performance
  • Formulaires multi-etapes avec validation serveur
  • Applications ou chaque milliseconde compte
Optimisations·Section 10/14

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

counter-store.tstypescript
1import { Store } from '@tanstack/store';
2
3// Creation d'un store type-safe
4interface 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 setState
15counterStore.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 courant
27console.log(counterStore.state.count); // 1
28
29// Se desabonner
30unsubscribe();

useStore : integration React

notification-store.tsxtsx
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 store
24export 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 change
55export 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 change
69export function NotificationList() {
70 const notifications = useStore(
71 notificationStore,
72 (state) => state.notifications
73 );
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 notification
80 </p>
81 ) : (
82 notifications.map((notification) => (
83 <div
84 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

cart-store-selectors.tsxtsx
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; // pourcentage
15}
16
17const cartStore = new Store<CartState>({
18 items: [],
19 couponCode: null,
20 couponDiscount: 0,
21});
22
23// Actions
24export 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 } : i
32 ),
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)} EUR
70 </span>
71 );
72}
73
74export function CartTotal() {
75 // Selecteur compose : sous-total avec reduction appliquee
76 const total = useStore(cartStore, (state) => {
77 const subtotal = state.items.reduce(
78 (sum, item) => sum + item.price * item.quantity,
79 0
80 );
81 const discount = subtotal * (state.couponDiscount / 100);
82 return subtotal - discount;
83 });
84
85 const hasDiscount = useStore(
86 cartStore,
87 (state) => state.couponDiscount > 0
88 );
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 appliquee
96 </span>
97 )}
98 </div>
99 );
100}

Comparaison des solutions de gestion d'etat

TanStack Store
Store immutable et reactif en ~2 KB, moteur interne de TanStack Form et Router.
Avantages
  • Ultra-leger (~2 KB)
  • Zero re-renders inutiles
  • Framework-agnostic
  • Pas de provider/context necessaire
Inconvenients
  • Ecosysteme de middleware limite
  • Pas de DevTools dediees
  • Communaute plus restreinte
Cas d'usage
  • Etat local partage entre composants proches
  • Librairies framework-agnostic
  • Cas ou le poids du bundle est critique
Zustand
Store minimaliste avec une API simple basee sur des hooks, middleware riche.
Avantages
  • API extremement simple
  • Middleware puissant (persist, devtools, immer)
  • Excellente documentation
  • Large adoption en production
Inconvenients
  • Specifique a React
  • Legerement plus lourd (~3 KB)
  • Selecteurs manuels pour eviter les re-renders
Cas d'usage
  • Etat global applicatif
  • Etat persiste (localStorage, sessionStorage)
  • Projets React avec besoin de middleware
React Context
Solution native de React pour le partage d'etat via l'arbre de composants.
Avantages
  • Aucune dependance externe
  • Integre nativement a React
  • Ideal pour les themes et preferences
Inconvenients
  • Re-rend tous les consommateurs a chaque changement
  • Pas de selecteurs natifs
  • Performance degradee pour l'etat qui change souvent
Cas d'usage
  • Theme, locale, authentification
  • Configuration qui change rarement
  • Props drilling sur 2-3 niveaux maximum

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
Bonnes Pratiques·Section 11/14

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

pacer-hooks-overview.tsxtsx
1import {
2 useDebouncedCallback,
3 useThrottledValue,
4 useQueuedState,
5} from '@tanstack/react-pacer';
6
7// -- useDebouncedCallback --
8// Execute le callback apres un delai d'inactivite
9// Cas d'usage : recherche, auto-save, validation
10const [debouncedSearch] = useDebouncedCallback(
11 (query: string) => {
12 fetchSearchResults(query);
13 },
14 { wait: 300 } // 300ms apres la derniere frappe
15);
16
17// -- useThrottledValue --
18// Limite la frequence de mise a jour d'une valeur
19// Cas d'usage : position de scroll, curseur, resize
20const [throttledPosition] = useThrottledValue(
21 mousePosition,
22 { wait: 16 } // ~60fps maximum
23);
24
25// -- useQueuedState --
26// File d'attente FIFO pour les mises a jour d'etat
27// Cas d'usage : notifications sequentielles, animations chainées
28const [currentItem, queue] = useQueuedState<Notification>({
29 maxSize: 10,
30 onProcess: (notification) => {
31 showToast(notification);
32 },
33});

Debouncing : recherche en temps reel

debounced-search.tsxtsx
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 change
28 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 caracteres
38 staleTime: 1000 * 60, // cache 1 minute
39 });
40
41 return (
42 <div className="relative w-full max-w-lg">
43 <input
44 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 <div
66 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 &laquo; {searchQuery} &raquo;
85 </div>
86 )}
87 </div>
88 );
89}

Throttling : gestionnaires de scroll et resize

throttled-scroll.tsxtsx
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 maximum
11 // Sans throttle, onScroll peut declencher 100+ events/seconde
12 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 brut
29 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 significatifs
34 if (delta > 10) {
35 setIsVisible(direction === 'up' || throttledScrollY < 100);
36 lastScrollY.current = throttledScrollY;
37 }
38 }, [throttledScrollY]);
39
40 return (
41 <header
42 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 progression
59 });
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 <div
75 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

rate-limited-api.tsxtsx
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 externes
12function useRateLimitedCallback<T extends (...args: any[]) => Promise<any>>(
13 callback: T,
14 config: RateLimitConfig
15) {
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 fenetre
25 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 limite
64 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 <input
76 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

toast-queue.tsxtsx
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 temps
15export 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 specifiee
28 useEffect(() => {
29 if (!currentToast) return;
30
31 const timer = setTimeout(() => {
32 setIsVisible(false);
33 // Attendre la fin de l'animation avant de traiter le suivant
34 setTimeout(() => {
35 setCurrentToast(null);
36 queue.next(); // Passer au toast suivant dans la file
37 }, 300);
38 }, currentToast.duration);
39
40 return () => clearTimeout(timer);
41 }, [currentToast, queue]);
42
43 // API publique pour ajouter des toasts
44 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 <div
58 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 attente
71 </p>
72 </div>
73 )}
74
75 {/* Boutons de demonstration */}
76 <div className="flex gap-2">
77 <button
78 onClick={() => addToast('Operation reussie', 'success')}
79 className="px-3 py-1.5 text-sm rounded-md bg-green-600 text-white"
80 >
81 Succes
82 </button>
83 <button
84 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 Erreur
88 </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.
Bonnes Pratiques·Section 12/14

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

providers.tsxtsx
1// Installation
2// npm install @tanstack/react-query-devtools
3
4// app/providers.tsx
5'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 minute
18 gcTime: 1000 * 60 * 5, // 5 minutes
19 },
20 },
21 })
22 );
23
24 return (
25 <QueryClientProvider client={queryClient}>
26 {children}
27 {/* Le panneau DevTools s'affiche en bas de l'ecran */}
28 <ReactQueryDevtools
29 initialIsOpen={false}
30 buttonPosition="bottom-right"
31 />
32 </QueryClientProvider>
33 );
34}

Lire le panneau React Query DevTools

devtools-guide.txttext
1// Guide de lecture du panneau React Query DevTools
2//
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 necessaire
8// -> staleTime n'est pas encore ecoule
9//
10// [stale] Jaune La donnee est perimee, sera refetchee au prochain trigger
11// -> staleTime est ecoule, attend un trigger (focus, mount, etc.)
12//
13// [fetching] Bleu Requete en cours d'execution
14// -> Le loader tourne, la requete est partie au serveur
15//
16// [paused] Gris La requete est en pause (mode offline)
17// -> Le navigateur est hors ligne, la requete reprendra
18//
19// [inactive] Gris Aucun composant n'observe cette query
20// fonce -> La donnee reste en cache pendant gcTime
21//
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 JSON
26// Observers : nombre de composants abonnes a cette query
27// Last Updated: timestamp de la derniere mise a jour
28// State : etat courant (fresh, stale, fetching, etc.)
29//
30// -- TIMERS IMPORTANTS --
31//
32// staleTime : duree pendant laquelle la donnee est consideree fraiche
33// Defaut: 0 (toujours stale). Recommande: 30s-5min selon le cas
34//
35// gcTime : duree de retention en cache apres que tous les observers
36// se sont desabonnes. Defaut: 5 minutes
37//
38// refetchInterval : intervalle de refetch automatique (si configure)
39//
40// -- ACTIONS DISPONIBLES --
41//
42// Refetch : Force un refetch immediat de la query
43// Invalidate: Marque la query comme stale, refetch au prochain trigger
44// Reset : Reinitialise la query a son etat initial
45// Remove : Supprime la query du cache

Router DevTools et Table DevTools

router-table-devtools.tsxtsx
1// -- TanStack Router DevTools --
2// Affiche l'etat complet du routeur : route active, params, search, loaders
3
4// npm install @tanstack/router-devtools
5import { 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 active
19// - Search params et path params en temps reel
20// - Etat des loaders (pending, success, error)
21// - Matches de route et composants rendus
22
23// -- TanStack Table DevTools --
24// Inspecte l'etat interne d'une instance de table
25
26// npm install @tanstack/react-table-devtools
27import { 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, pagination
42// - Donnees brutes de chaque ligne
43// - Colonnes visibles et leur configuration
44// - Performance de rendu des cellules

Panneau DevTools unifie

unified-devtools.tsxtsx
1// Configuration unifiee pour un projet utilisant plusieurs outils TanStack
2// Tous les DevTools sont accessibles depuis un seul point d'entree
3
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 conditionnel
11export 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 <ReactQueryDevtools
35 initialIsOpen={false}
36 buttonPosition="bottom-right"
37 />
38
39 {/* En production, les devtools sont automatiquement exclus
40 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 via React.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

lazy-devtools.tsxtsx
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 necessaire
7const ReactQueryDevtools = lazy(() =>
8 import('@tanstack/react-query-devtools').then((mod) => ({
9 default: mod.ReactQueryDevtools,
10 }))
11);
12
13// Detecter si les devtools doivent etre actives
14function shouldShowDevtools(): boolean {
15 if (typeof window === 'undefined') return false;
16
17 // Mode developpement : toujours actif
18 if (process.env.NODE_ENV === 'development') return true;
19
20 // Mode production : actif uniquement via query param secret
21 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 4xx
35 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 <ReactQueryDevtools
53 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.
Avance·Section 13/14

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

app/users/page.tsxtsx
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 secondes
14 );
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 serveur
28 const queryClient = new QueryClient();
29
30 // Prefetch : la donnee est mise en cache AVANT le rendu
31 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

app/users/user-list.tsxtsx
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 fetch
36 staleTime: 1000 * 30, // considerer frais pendant 30 secondes
37 });
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 <button
72 onClick={() => goToPage(page - 1)}
73 disabled={page <= 1}
74 className="px-4 py-2 rounded-md border text-sm disabled:opacity-50"
75 >
76 Precedent
77 </button>
78 <span className="text-sm text-muted-foreground">
79 Page {data?.currentPage} sur {data?.totalPages}
80 </span>
81 <button
82 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 Suivant
87 </button>
88 </div>
89 </div>
90 );
91}

Streaming avec Suspense

streaming-dashboard.tsxtsx
1// app/dashboard/page.tsx
2// Pattern : prefetch multiple + streaming progressif
3import {
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 separees
14async 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 parallele
34 // Les donnees lentes seront streamees via Suspense
35 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 ici
47 // Elle sera chargee en streaming cote client
48
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 <Suspense
58 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.tsx
71'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 parent
83 // Le composant ne se rend que lorsque les donnees sont disponibles
84 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 minutes
91 });
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 actifs
102 </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

Pages Router
Architecture originale de Next.js avec getServerSideProps/getStaticProps pour le data fetching.
Avantages
  • Ecosysteme mature et stable
  • Documentation abondante
  • Patterns bien etablis
  • Compatible avec toutes les librairies existantes
Inconvenients
  • Data fetching couple au fichier page
  • Hydratation manuelle du cache Query
  • Pas de Server Components natifs
  • Waterfall de requetes difficile a eviter
Cas d'usage
  • Projets existants en migration progressive
  • Equipes habituees au modele Pages Router
  • Applications avec contraintes de compatibilite
App Router
Architecture moderne de Next.js avec Server Components, Streaming et prefetching integre.
Avantages
  • Server Components natifs
  • Streaming et Suspense integres
  • prefetchQuery + dehydrate optimise
  • Layouts imbriques et paralleles
Inconvenients
  • Courbe d'apprentissage plus elevee
  • Certaines librairies pas encore compatibles
  • Debugging plus complexe (server vs client)
Cas d'usage
  • Nouveaux projets Next.js
  • Applications avec fort besoin SEO
  • Projets necessitant du streaming
TanStack Start
Framework full-stack de TanStack avec routeur type-safe, SSR integre et data fetching optimal.
Avantages
  • 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
Inconvenients
  • Ecosysteme encore jeune
  • Moins de ressources communautaires
  • Pas de deploiement Vercel optimise
  • API en evolution
Cas d'usage
  • Projets greenfield avec ecosysteme TanStack complet
  • Applications necessitant un routeur type-safe
  • Equipes familiarisees avec TanStack
Avance·Section 14/14

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

api-layer.tstypescript
1// Structure recommandee pour un projet TanStack en production
2//
3// src/
4// ├── api/ # Couche HTTP pure
5// │ ├── client.ts # Instance fetch/axios configuree
6// │ ├── users.api.ts # Endpoints utilisateurs
7// │ ├── products.api.ts # Endpoints produits
8// │ └── orders.api.ts # Endpoints commandes
9// │
10// ├── queries/ # Query factories (queryOptions)
11// │ ├── users.queries.ts # Factories pour les queries utilisateurs
12// │ ├── products.queries.ts # Factories pour les queries produits
13// │ └── orders.queries.ts # Factories pour les queries commandes
14// │
15// ├── hooks/ # Hooks personnalises (logique metier)
16// │ ├── use-users.ts # Hook combinant queries + mutations users
17// │ ├── use-cart.ts # Hook panier (queries + store + mutations)
18// │ └── use-auth.ts # Hook auth (queries + redirections)
19// │
20// └── components/ # Composants React
21
22// -- 1. Couche API : fonctions HTTP pures, sans React --
23// src/api/client.ts
24const 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?: RequestInit
35): 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()

users.queries.tstypescript
1// src/queries/users.queries.ts
2import { queryOptions } from '@tanstack/react-query';
3import { usersApi, type UsersListParams } from '@/api/users.api';
4
5// Query factory : centralise les queryKeys et queryFn
6// queryOptions() infere automatiquement le type de retour
7
8export const usersQueries = {
9 // Cle racine pour toutes les queries utilisateurs
10 all: () => queryOptions({
11 queryKey: ['users'] as const,
12 }),
13
14 // Liste paginee avec filtres
15 list: (params: UsersListParams = {}) => queryOptions({
16 queryKey: ['users', 'list', params] as const,
17 queryFn: () => usersApi.list(params),
18 staleTime: 1000 * 30, // 30 secondes
19 }),
20
21 // Detail d'un utilisateur
22 detail: (id: string) => queryOptions({
23 queryKey: ['users', 'detail', id] as const,
24 queryFn: () => usersApi.getById(id),
25 staleTime: 1000 * 60, // 1 minute
26 enabled: !!id, // ne fetch pas si id est vide
27 }),
28};
29
30// -- Utilisation dans les composants --
31
32import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
33
34// Dans un composant de liste
35function 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 detail
44function UserProfile({ userId }: { userId: string }) {
45 // Type automatiquement infere : User
46 const { data: user } = useQuery(usersQueries.detail(userId));
47 // user.name, user.email, etc. sont type-safe
48}
49
50// Invalidation precise
51function 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 modifie
59 queryClient.invalidateQueries({ queryKey: usersQueries.list().queryKey });
60 queryClient.invalidateQueries({ queryKey: usersQueries.detail(id).queryKey });
61 },
62 });
63}
64
65// Prefetching dans un Server Component
66// app/users/page.tsx
67export default async function UsersPage() {
68 const queryClient = new QueryClient();
69
70 // Reutilise exactement la meme query factory
71 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()

generic-query-factory.tstypescript
1// Patterns TypeScript avances pour des queries entierement type-safe
2
3import { queryOptions, type QueryKey } from '@tanstack/react-query';
4
5// -- Pattern 1 : Query factory generique --
6// Reutilisable pour n'importe quelle entite CRUD
7
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 patterns
41const 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 infere
63function ProductPage() {
64 const { items, total } = useEntityList(productsQueries);
65 // items est Product[], total est number
66 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-safe
76function invalidateUsersQueries(
77 queryClient: QueryClient,
78 key: UsersQueryKey
79) {
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

Inline (debutant)
Les queries sont declarees directement dans les composants, sans abstraction.
Avantages
  • Simple et rapide a ecrire
  • Pas de fichier supplementaire
  • Bon pour les prototypes
Inconvenients
  • Duplication des queryKeys
  • queryFn dupliquee dans plusieurs composants
  • Pas de reutilisabilite
  • Refactoring couteux
Cas d'usage
  • Prototypes et preuves de concept
  • Composants avec une seule query unique
Query Factories (recommande)
Les queries sont organisees en factories avec queryOptions(), reutilisables et type-safe.
Avantages
  • Reutilisabilite maximale
  • queryKeys centralises et coherents
  • Type inference automatique
  • Invalidation precise et previsible
Inconvenients
  • Fichiers supplementaires a creer
  • Courbe d'apprentissage initiale
  • Peut sembler excessif pour les petits projets
Cas d'usage
  • Applications de production
  • Equipes de plus de 2 developpeurs
  • Projets avec plus de 10 queries
Custom Hooks (compose)
Les query factories sont encapsulees dans des hooks personnalises avec logique metier.
Avantages
  • Encapsulation de la logique metier
  • API simplifiee pour les consommateurs
  • Testabilite amelioree
  • Combine factory + logique applicative
Inconvenients
  • Couche d'abstraction supplementaire
  • Risque de sur-ingenierie
  • Plus de code a maintenir
Cas d'usage
  • Queries avec logique metier complexe
  • Compositions de queries et mutations
  • Logique partagee entre plusieurs pages

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

tanstack-ecosystem-recap.txttext
1// Resume : quand utiliser chaque outil TanStack
2//
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.