TanStack DB : la base de données client qui rend vos apps web instantanées
Pourquoi les applications web semblent-elles toujours plus lentes que les apps natives ? Et si le problème venait de la façon dont on gère les données côté client ?
Dans un récent article, je vous présentais comment Tanstack Query révolutionnait le data fetching dans nos apps en :
- supprimant le code boilerplate nécessaire pour fetcher les données
- permettant de partager les données à travers les composants de l’application de manière simple et transparente.
Mais si vos données commencent à grossir (et à vous regarder méchamment) vous allez peut être avoir le sentiment de manquer de puissance.
Mais c’était sans compter sur l’équipe TanStack : Ils ont pris TanStack Query, l’ont blindé de nouvelles features et ont livré en Juillet 2025 une version béta de TANSTACK DB !

Dans ce nouvel article, on va explorer ce qu'est TanStack DB, les problèmes qu'il résout, sa philosophie, et comment il fonctionne techniquement.
Le problème : pourquoi TanStack Query ne suffit plus
Si vous développez des applications React (ou Vue, Solid, Svelte), vous connaissez probablement TanStack Query (ex-React Query). C'est un excellent outil pour gérer le "server state" : cache intelligent, retries automatiques, refetch en arrière-plan, devtools...
Mais à mesure que les applications grandissent, certaines limitations deviennent frustrantes.
1. Des données isolées, sans relations
Avec TanStack Query, chaque requête vit dans son propre cache. Imaginons une application de Todos….

Ok... alors pour rester dans le thème, imaginons une application qui recense les personnages Marvel et l'"équipe" à laquelle ils appartiennent. Si vous avez des characters et des teams, ces données ne se connaissent pas.
export interface Character {
id: number;
name: string;
alias: string;
teamId: number;
isHuman: boolean;
hasSuperPower: boolean;
}
export interface Team {
id: number;
name: string;
status: "active" | "disbanded" | "defeated";
}
export const characters: Character[] = [
{ id: 1, name: "Tony Stark", alias: "Iron Man", teamId: 1, isHuman: true, hasSuperPower: false },
{ id: 2, name: "Steve Rogers", alias: "Captain America", teamId: 1, isHuman: true, hasSuperPower: true },
...
{ id: 29, name: "Wong", alias: "Wong", teamId: 4, isHuman: true, hasSuperPower: true },
];
export const teams: Team[] = [
{ id: 1, name: "Avengers", status: "active" },
{ id: 2, name: "Guardians of the Galaxy", status: "active" },
{ id: 3, name: "Wakanda", status: "active" },
{ id: 4, name: "Masters of the Mystic Arts", status: "active" },
{ id: 5, name: "S.H.I.E.L.D.", status: "disbanded" },
{ id: 6, name: "Hydra", status: "defeated" },
{ id: 7, name: "Defenders", status: "disbanded" },
];
Imaginons que vous vouliez afficher "Tous les personnages avec super pouvoirs dont l'équipe est active". Même avec TanStack Query, vous devez écrire quelque chose comme :
const result = characters.filter(character => {
const team = teams.find(t => t.id === character.teamId);
return character.hasSuperPower && team?.status === "active";
});
Ça fonctionne avec quelques dizaines d'éléments. Avec des milliers ? Ça risque de ramer... Et ce filtre se ré-exécute à chaque rendu.
Le dilemme backend vs. frontend
Dans leur article Stop Re-Rendering. TanStack DB, the Embedded Client Database for TanStack Query Kyle Mathews et Sam Willis - les créateurs de TanStack DB - résument bien les alternatives qui s’offrent aux équipes face à un tel besoin :
- Option A : Créer un endpoint spécifique (ou on utilise un endpoint générique avec query params). Ça multiplie les endpoints côté backend et complexifie le frontend (nouveau cache, données dupliquées).
- Option B : Tout charger et filtrer côté client. Simple côté backend, mais lent côté client.
Vous le reconnaîtrez, aucune de ces options n’est pleinement satisfaisante.
2. Les mutations optimistes sont verbeuses
Vous ne connaissez pas le principe des mutations optimistes ou l'optimitic update ?
Imaginez : Vous êtes avec quelques amis à la table d'un restaurant. Une serveuse vient prendre la commande, mais à chaque fois que vous ou l'un de vos amis énonce un plat, le serveur court en cuisine s'assurer que le plat est disponible, puis revient prendre le reste de la commande ! Absurde non ? C'est pourtant l'expérience utilisateur que la plupart des applications web proposent :
A chaque fois qu'on demande de modifier une donnée, tout est stoppé et on reprend une fois la confirmation de la modification reçue...
Si nous reprenons notre exemple initial, dans la vraie vie, le serveur part du principe que tous les plats à la carte sont disponibles, prend toutes les commandes et dans de rares cas, revient annoncer aux client qu'un plat en particulier n'est plus disponible... Fondamentalement, notre serveur est optimiste !
👆 Meilleure métaphore restauration / tech depuis The promise of a burger party de Mariko Kosaka ! 🏆 😎
L'optimistic update est une manière de gérer un formulaire dans laquelle l'application partira du principe que chaque modification de donnée demandée se passera bien !
- 📋 L'utilisateur soumet le formulaire
- ⚡️ On met à jour l'état local immédiatement (l'utilisateur est content, c'est instantané de son point de vue !)
- 🚀 On lance la requête
- ✅ Succès → on confirme
- ❌ Erreur → on rollback l'état précédent et on affiche éventuellement un message d'erreur
Avec TanStack Query, il est déjà possible d’implémenter l’optimistic update mais de manière manuelle :
useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ["todos", newTodo.id] });
const previousTodo = queryClient.getQueryData(["todos", newTodo.id]);
queryClient.setQueryData(["todos", newTodo.id], newTodo);
return { previousTodo, newTodo };
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(["todos", context.newTodo.id], context.previousTodo);
},
onSettled: (newTodo) => {
queryClient.invalidateQueries({ queryKey: ["todos", newTodo.id] });
},
});
Beaucoup de boilerplate pour une seule mutation. Et vous devez le répéter pour chaque action.
TanStack DB va résoudre ces deux problèmes de manière fort élégante !
Qu'est ce que TanStack DB ?
TanStack DB, c'est un base de données côté client. Cette base de données va nous permettre de résoudre les différents problèmes suscités en servant de cache pour notre application.
La philosophie de TanStack DB
TanStack DB part d'un constat simple : le réseau est le goulot d'étranglement de l'UX moderne.
Chaque fois que l'utilisateur doit attendre une réponse serveur, l'expérience se dégrade. Les meilleures applications (Linear, Figma, Notion) ont compris ça : elles fonctionnent comme si les données étaient locales.
L'approche "Offline First"
Le paradigme offline-first concerne les applications conçues pour pouvoir fonctionner sans réseaux avec des données stockées localement. L'approche offline first implique un certain nombre de problématiques à gérer :
- Stockage local des données : l'application interagit d'abord avec les données locales
- Synchronisation en arrière plan : en présence de réseau les données locales se synchronisent avec le cloud
- Mises à jour optimistes
- Système de résolution de conflit : Il faut pouvoir résoudre les conflits entres les données modifiées par différents clients
⚠️ Ne pas confondre l'approche Offline First et l'approche Local First. Dans une approche Offline First, la source de vérité reste la donnée serveur (le cloud). Dans une approche Local First, la source de vérité reste la donnée sur le client. Le cloud n'est qu'un espace de sauvegarde dans lequel l'utilisateur peut envoyer explicitement des données.
- Local First vs Offline First in 100 seconds
- Architecture Offline First - Montpellier JS
| Approche traditionnelle | Approche offline-first |
|---|---|
| Action utilisateur → Requête serveur → Attente → Réponse → Mise à jour UI | Action utilisateur → Mise à jour UI immédiate → Synchronisation du serveur en arrière-plan |
L'utilisateur n'attend jamais. Les données sont manipulées localement, puis synchronisées.
Une troisième option...
Pour ses créateurs, TanStack DB propose une troisième voie face au dilemme du filtrage backend / frontend que nous avons évoqué :
Charger des collections normalisées avec moins d’appels API, puis effectuer des jointures incrémentales ultra-rapides côté client. Vous bénéficiez de l’efficacité réseau d’un chargement de données large, avec des performances de requête inférieures à la milliseconde […]
Vous gardez un backend simple (endpoints génériques), et vous gagnez en performance côté client grâce à un moteur de requêtes ultra-optimisé.
Comment ça marche : les concepts clefs de TanStack DB
TanStack DB étend TanStack Query avec trois nouveaux concepts de base : les Collections, les Live Queries, et les Mutations transactionnelles.
1. Collections : des ensembles typés de données
Une Collection est un conteneur pour vos données. Elle peut être alimentée localement, par une API REST, GraphQL, ou un par un sync engine.
import { queryCollectionOptions } from "@tanstack/query-db-collection";
import {
createCollection,
localOnlyCollectionOptions,
} from "@tanstack/react-db";
// Collection alimentée localement
export const charactersCollection = createCollection<Character, number>(
localOnlyCollectionOptions({
id: "characters",
getKey: (character) => character.id,
initialData: characters,
})
);
// Collection alimentée par une API
export const charactersCollection = createCollection<Character, number>(
queryCollectionOptions({
queryKey: ["characters"],
queryFn: async () => {
const response = await fetch("/api/characters")
return response.json()
},
queryClient,
getKey: (character) => character.id,
})
)
Les Collections sont typées et normalisées. Chaque élément est identifié par une clé unique, ce qui évite les duplications.
2. Live Queries : des requêtes réactives ultra-rapides
Les Live Queries permettent de filtrer, joindre et agréger les données des Collections. Leur syntaxe ressemble à SQL.
Pour récupérer tous films, on écrit ceci :
import { useLiveQuery, eq } from "@tanstack/react-db";
// Récupérer tous les personnages
const { data: characters } = useLiveQuery((q) =>
q.from({ c: charactersCollection })
);
// Récupérer toutes les équipes
const { data: teams } = useLiveQuery((q) =>
q.from({ t: teamsCollection })
);
Si nous reprenons l'exemple précédent dans lequel nous souhaitions récupérer "Tous les personnages avec des super pouvoirs dont l'équipe est active", avec TanStack DB, il nous suffit de créer une jointure dans live query :
import { useLiveQuery, eq, and } from "@tanstack/react-db";
const { data } = useLiveQuery((q) =>
q
.from({ characters: charactersCollection })
.join({ teams: teamsCollection }, ({ characters, teams }) =>
eq(characters.teamId, teams.id)
)
.where(({ characters, teams }) =>
and(eq(characters.hasSuperPower, true), eq(teams.status, "active"))
)
.select(({ characters, teams }) => ({
id: characters.id,
alias: characters.alias,
name: characters.name,
teamName: teams!.name,
}))
);
// Le chaînage :
// 1. .from() — source principale
// 2. .join() — jointure sur c.teamId = t.id
// 3. .where() — filtre combiné avec and() sur les deux collections
// 4. .select() — projection des champs voulus (évite les conflits de noms comme id)
Ces live queries peuvent être utilisées pour créer une nouvelle collection via les live query collections. Par exemple, si je veux récupérer un objet character avec les informations de sont équipe, je peux créer une collection characterWithTeam dans laquelle chaque object character possèdera également le statut de son équipe.
La magie du Differential Dataflow
Ce qui rend les Live Queries si rapides, c'est leur implémentation basée sur le differential dataflow (via la bibliothèque d2ts).
Au lieu de ré-exécuter toute la requête quand une donnée change, le moteur calcule uniquement ce qui a changé (le "delta"). Résultat : des mises à jour en moins d'une milliseconde, même sur des collections de 100 000+ éléments.
C'est aussi ce qui permet la réactivité fine : seuls les composants affectés par un changement se re-rendent.
3. Mutations transactionnelles : optimistes par défaut
Les mutations dans TanStack DB sont optimistes par défaut et transactionnelles (ce qui permet le rollback automatique en cas d'erreur). Si nous voulons gérer la modification d’un personnage, il nous suffit de reprendre la déclaration de notre collection et d’ajouter la propriété onUpdate :
export const charactersCollection = createCollection(
queryCollectionOptions({
queryKey: ["characters"],
queryFn: async () => {
const response = await fetch("/api/characters")
return response.json()
},
queryClient,
getKey: (character) => character.id,
onUpdate: async ({ transaction }) => {
for (const mutation of transaction.mutations) {
await fetch(`/api/characters/${mutation.key}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(mutation.modified),
})
}
}
})
)
// Utilisation : une seule ligne !
charactersCollection.update(id, (draft) => {
draft.isFavourite = !current.isFavourite;
});
Comparez avec le boilerplate de TanStack Query. Ici, tout est géré automatiquement : mise à jour immédiate de l'UI, rollback si l'API échoue, invalidation du cache.
Et c'est exactement la même logique si nous voulons ajouter ou supprimer un personnage avec les propriétés onInsert et onDelete :
export const charactersCollection = createCollection(
queryCollectionOptions({
queryKey: ["characters"],
queryFn: async () => {
const response = await fetch("/api/characters")
return response.json()
},
queryClient,
getKey: (character) => character.id,
onInsert: async ({ transaction }) => {
for (const mutation of transaction.mutations) {
await fetch("/api/characters", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(mutation.modified),
})
}
},
onUpdate: async ({ transaction }) => {
for (const mutation of transaction.mutations) {
await fetch(`/api/characters/${mutation.key}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(mutation.modified),
})
}
},
onDelete: async ({ transaction }) => {
for (const mutation of transaction.mutations) {
await fetch(`/api/characters/${mutation.key}`, {
method: "DELETE",
})
}
},
})
)
Aller plus loin avec les Sync Engines
Pour aller plus loin dans la philosophie Local First, vous pouvez coupler TanStack DB avec l’un des Sync Engine (par exemple ElectricSQL ou Firebase).
Qu'est-ce qu'un sync engine ?
Un sync engine est une couche qui maintient les données synchronisées entre votre base de données serveur et vos clients. Exemples : ElectricSQL, PowerSync, Firebase, Replicache.
Avec un sync engine, vous obtenez :
- Temps réel automatique : les changements sont poussés à tous les clients instantanément
- Sync incrémentale : seuls les deltas sont téléchargés, pas toute la base
- Offline-first : l'app fonctionne sans connexion, sync quand le réseau revient
📚Pour plus d'information sur le Sync Engines :
The Syncing Era of the Web
Local-first sync with Electric and TanStack DB
Quand utiliser TanStack DB ?
✅ Cas d'usage idéaux | ❌ Cas où ça n'apporte pas grand-chose |
|---|---|
|
|
Conclusion
TanStack DB représente une évolution naturelle dans la gestion des données côté client. En combinant :
- Des Collections normalisées pour structurer les données
- Des Live Queries ultra-rapides grâce au differential dataflow
- Des Mutations optimistes sans boilerplate
- Une intégration native avec les sync engines
...il permet de construire des applications qui se sentent instantanées, comme les meilleures apps natives.
Le plus intéressant ? Vous pouvez l'adopter progressivement. Commencez par remplacer une query TanStack Query par une Collection, puis une autre. Votre backend n'a pas besoin de changer.
La version 1.0, initialement prévue pour fin 2025, est toujours attendue. Le projet est actuellement en version 0.5 (beta). C'est le moment idéal pour expérimenter et se préparer à cette nouvelle façon de construire des applications web.
BONUS : L'approche Local First de TanStack DB facilite le bouchonnage de vos données
Autre avantage de l'approche Local First de TanStack DB, c'est le fait de pouvoir facilement mocker vos données :
Vos collections ne se soucient pas d'où viennent les données, vous pouvez les remplir avec un vrai appel API ou un fichier JSON par exemple. Conséquence ? Cela facilite grandement le bouchonnage de vos données.
import { createCollection } from "@tanstack/react-db";
import { queryCollectionOptions } from "@tanstack/query-db-collection";
import { QueryClient } from "@tanstack/react-query";
// ===========================================
// TYPE
// ===========================================
interface Todo {
id: number;
title: string;
completed: boolean;
}
const queryClient = new QueryClient();
// ===========================================
// Collection "normale" avec API
// ===========================================
const todoCollection = createCollection(
queryCollectionOptions({
queryKey: ["todos"],
queryFn: (): Promise<Todo[]> =>
fetch("/api/todos").then((r) => r.json()),
getKey: (item: Todo) => item.id,
queryClient,
})
);
// ===========================================
// Collection mockée
// ===========================================
const mockTodoCollection = createCollection(
queryCollectionOptions({
queryKey: ["todos"],
queryFn: (): Promise<Todo[]> =>
Promise.resolve([
{ id: 1, title: "Faire les courses", completed: false },
{ id: 2, title: "Appeler maman", completed: true },
]),
getKey: (item: Todo) => item.id,
queryClient,
})
);
Ainsi, une fois vos collections chargées, vous pouvez tester l'UX de votre application en situation de création, de modification et de suppression de données.