-->
Learn what TanStack DB is, how it works, and why it might change the way you build apps.
A bit over a month ago, the TanStack team welcomed the newest member to its family: TanStack DB.
And as I often do whenever I learn about a new TanStack library, I dropped everything I was doing and went straight to the docs to learn more about it:
TanStack DB is a reactive client store for building super fast apps on sync. Built on a Typescript implementation of differential dataflow, TanStack DB gives you real-time sync, live queries and local writes. With no stale data, super fast re-rendering and sub-millisecond cross-collection queries — even for large complex apps.
All this looked really exciting. I mean, who doesn’t want super fast re-rendering and sub-millisecond cross-collection queries?
But… I still had a lot of questions.
“So is TanStack DB a sync engine? A state management tool? A database like MySQL? An ORM like Prisma? Or something else entirely? Also, what on earth is a differential dataflow?”
To better understand how TanStack DB works, I spent the last week playing around with it and I put all of my learnings together in this article—along with some nice visualizations that will hopefully help clear things up and distract you from how terrible some of the jokes are.
So I hope you’ll join me in this journey (that was a database pun. I did warn you about the jokes, didn’t I?) to understand what TanStack DB is, how it works, and how we can unlock all of its power.
Let’s dive right in.
TanStack DB was designed to fill some of the gaps in TanStack Query, so one of the best ways to see what it brings to the table is to compare it with a Query implementation.
Let’s take this simple Todo app as an example:
You can click around the app to see how it works, but it’s pretty straightforward: we have a list of projects, some of which can be “favorited”, and each project has a list of todos, which can be completed or not.
To keep things simple, our app doesn’t allow users to add or remove todos, so I guess they’re doomed to completing the same tasks over and over. Hey, I never said it was a good todo app.
Okay, I can see there are some raised eyebrows in the room. You, sir, in the back, what’s on your mind?
“A todo app? Booooring! Wasn’t there a more interesting example?
Boo this man! Booooo!”
I know, I know! I’m sorry, OK?
I promise a todo app is a good example, though. Just bear with me for the next few paragraphs and you’ll see what I mean.
We have a couple of API endpoints at our disposal to get the data we need to render our app:
GET /projects
, which gives us the list of projects, including whether they’re a “favorite” project or not.
[
{ id: 1, name: "Family", isFavorite: true },
{ id: 2, name: "Work", isFavorite: true },
{ id: 3, name: "Home", isFavorite: false },
{ id: 4, name: "Blog", isFavorite: false },
...
]
And GET /todos
which gives us the list of todos, including which projects they belong to, and whether or not they’re completed.
[
{ id: 1, name: "Call mom", completed: false, projectId: 1 },
{ id: 2, name: "Plan summer trip", completed: true, projectId: 1 },
{ id: 3, name: "Buy birthday present", completed: true, projectId: 1 },
{ id: 4, name: "Review PRs", completed: false, projectId: 2 },
{ id: 5, name: "Draft spec doc", completed: false, projectId: 2 },
...
]
“Hmm. I see you’re getting all todos for all projects in a single request, but that’s not too web-scale, is it? What if I have literally a million things to do?”
That’s a good point, suspicious developer. As my friend C likes to say, “You’re absolutely right!”
In this example, we’re indeed getting all the todos for all projects in a single request. We can get away with that because we’re dealing with only a handful of items, but in a real application where we might be dealing with dozens of projects and hundreds of items per project, we’d probably want to fetch them on a per-project basis. We’ll come back to this point in a minute.
We also have a PATCH /todos/{id}
endpoint that we’ll use to toggle the completed status of a todo.
Alright, let’s see how we can implement this app with TanStack Query. To do that, we’ll need a few pieces.
We’re using React in this example, so you’ll see some React-specific code like
useQuery
and useMutation
. If React is not your cup of tea, don’t worry.
TanStack Query and TanStack DB support Vue, Solid, and Svelte as well.
First, we’ll need to define one query
per model to get the data from the API:
import { useQuery } from "@tanstack/react-query";
// ...
const { data: projects } = useQuery({
queryKey: ["projects"],
queryFn: async () => {
const res = await fetch("/api/projects");
return res.json();
},
});
const { data: todos } = useQuery({
queryKey: ["todos"],
queryFn: async () => {
const res = await fetch("/api/todos");
return res.json();
},
});
// ...
And we’ll also need to define one mutation
per action the user can take. For instance, here’s the mutation to mark a todo item as completed:
import { useMutation } from "@tanstack/react-query";
// ...
const { mutate: updateTodo } = useMutation({
mutationFn: async (todo: Todo) => {
const res = await fetch(`/api/todos/${todo.id}`, {
method: "PATCH",
body: JSON.stringify(todo),
});
return res.json();
},
onSuccess: () => {
// Invalidate the todos query to refetch the updated list
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
// ...
This is a pretty clean implementation, if you ask me. And more importantly, by using TanStack Query we’re getting a ton of sweet features essentially for free: smart cache management, automatic retries, background refetching, super cool devtools, and much more.
So what’s wrong with it? You might ask. Well, nothing is wrong. This is more than enough for the little todo app we’re building. But depending on how the app scales and evolves over time, we might run into some of Query’s limitations.
One of those limitations is that there is no relationship between our models. The data for our projects and todos live in isolated caches, so if we wanted to mix and match them somehow, we don’t have an easy way to do it.
Imagine we wanted to add a feature to our app that shows uncompleted todos for all of our favorite projects. To do that, we’d need to do some manual joining and filtering like the following:
const uncompletedFavoriteTodos = todos
.filter((todo) => !todo.completed)
.filter((todo) =>
projects.some(
(project) => project.id === todo.projectId && project.isFavorite
)
);
This isn’t terrible on a small scale, but with hundreds or thousands of items, it could become a performance bottleneck. Not to mention that this approach would only work with our strategy of fetching all todos for all projects every time. If we ever switch to fetching todos on a per-project basis, this would no longer be an option.
A more sensible approach in this case would be to do the filtering on the backend—expose a new GET /favorite-todos
endpoint and use it to power the new feature. This is a good option, but:
query
with its own cache which will likely contain duplicated data from other queries, andWe’re only talking about a single teeny-tiny feature here. Let’s say our todo app is a big success (unlikely, I know, but one can dream) and we wanted to add some new features to it:
Every new feature we add to the app comes with the same challenges. We have to decide whether the business logic should live on server or the client (that is, if we have the data available), and deal with the consequences of managing changing client-state (i.e. deriving state in the right places, keeping re-renders in check, memoization, and so on.)
The other big limitation of TanStack Query has to do with optimistic updates.
As you might have noticed, the todo app demo you played with above is not a real app. The data is all stored in memory, so it doesn’t make any requests anywhere.
This is how our app would look if it were saving the data in a remote database. Try clicking a todo item in the demo below:
That doesn’t feel great, does it? It’s not too big of a delay (it’s less than a second), but the fact that the items don’t update immediately after checking them off is very unsatisfying.
The reason for the delay is that our current implementation is not optimistic—after the user checks a todo item, the UI has to wait for not one, but two requests to complete before updating itself: the PATCH
request that updates the todo, and the GET
request that brings us the updated list.
Here’s a step by step breakdown of what’s happening:
Luckily for us, TanStack Query gives us a way to make our update optimistic by hooking into various mutation callbacks. Here’s how our “update todo” mutation might look like if we wanted to make it optimistic (borrowed from the TanStack Query docs):
useMutation({
mutationFn: () => { ... }, // Same as our original mutationFn
onMutate: async (newTodo) => {
// Cancel any outgoing refetches
// (so they don't overwrite our optimistic update)
await queryClient.cancelQueries({ queryKey: ["todos", newTodo.id] });
// Snapshot the previous value
const previousTodo = queryClient.getQueryData(["todos", newTodo.id]);
// ⭐ Optimistically update to the new value
queryClient.setQueryData(["todos", newTodo.id], newTodo);
// Return a context with the previous and new todo
return { previousTodo, newTodo };
},
// If the mutation fails, use the context we returned above
onError: (err, newTodo, context) => {
queryClient.setQueryData(
["todos", context.newTodo.id],
context.previousTodo
);
},
// Always refetch after error or success:
onSettled: (newTodo) => {
queryClient.invalidateQueries({ queryKey: ["todos", newTodo.id] });
},
});
This isn’t a ton of work, to be honest, but it is a lot of boilerplate. The only line in this entire block that is unique to this specific mutation is the one marked with a ⭐ (this is the part of the mutation that is recreating the server-side logic on the client.)
The rest of the code in this example—cancelling queries, keeping the snapshot of the previous value, rolling back in case of an error, refetching after error or success—are things you’d want to do for any optimistic mutation. If you have several of these across your codebase, chances are you’re going to be doing a lot of copy-pasting.
And we’re only talking about a simple example here. If we wanted to handle more advanced edge cases, like concurrent optimistic updates, things could get a lot hairier pretty quickly.
But that’s enough talking about drawbacks and limitations (that’s not too optimistic of us, isn’t it?) Let’s now finally talk about TanStack DB and see how it can help us tackle some of these challenges.
Let’s go back to the TanStack DB definition we saw at the beginning, but let’s break it down a little bit to make it easier to digest.
TanStack DB is a reactive client store that extends TanStack Query with three core primitives:
The fact that it’s reactive means that our app can benefit from fine-grained state updates that minimize component re-rendering.
And the fact that it’s built on top of Query means that we get all of the benefits we talked about earlier, plus a bunch of new features thanks to these new primitives. Let’s talk about each one in a bit more detail.
Collections are typed sets of objects that can be populated with data. A collection can represent a table in your database, a model in your domain, or the response of an API call.
We can populate collections with data by:
The first step for adopting TanStack DB in our todo app is to define collections for our app’s models: one for projects, and one for todos. Here’s how the todoCollection
definition looks like:
import { createCollection } from "@tanstack/react-db";
import { queryCollectionOptions } from "@tanstack/query-db-collection";
const queryClient = new QueryClient();
const todoCollection = createCollection(
queryCollectionOptions({
queryKey: ["todos"],
queryFn: async () => {
const response = await fetch("/api/todos");
return response.json();
},
queryClient,
getKey: (item) => item.id,
})
);
// ...
Here, we’re using the queryCollectionOptions
function from the @tanstack/query-db-collection
package to set up the collection. This creates a Query Collection, which seamlessly integrates with TanStack Query. We’d typically use this option when fetching data from a remote source, like our app’s REST API.
Live Queries are used to read data from collections. They’re powered by a powerful query system that we can use to filter, transform and aggregate data from collections (or from the result of other live queries, since they can be chained.)
They’re live queries because they automatically update when the underlying data changes, and thanks to the differential dataflow they’re implemented with, they’re designed to be extremely fast—they can filter collections of hundred of thousands of elements in less than a millisecond.
Live queries have a SQL-like API, so if you’re familiar with query builders like Drizzle or Prisma, you’ll feel right at home. Here’s how a live query to get our list of todos for a particular project would look like:
import { useLiveQuery } from "@tanstack/react-db";
// ...
const projectId = getSelectedProjectId();
const { data: todos } = useLiveQuery((q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => todo.projectId === projectId)
);
Live queries have a ton of features, including the ability to join multiple collections. This makes use cases that involve mixing and matching data from multiple collections a breeze to implement.
For instance, if we wanted to implement the feature to show uncompleted todos for all of our favorite projects using a live query, it would look something like this:
const { data: uncompletedFavoriteTodos } = useLiveQuery((q) =>
q
.from({ todo: todoCollection })
.join({ project: projectCollection }, ({ todo, project }) =>
eq(todo.projectId, project.id)
)
.where(({ todo, project }) => !todo.completed && project.isFavorite)
);
That’s it! No backend changes, no new endpoints, and thanks to the fine-grained reactivity of TanStack DB, no client-side performance bottlenecks. This still assumes that we’re fetching all todos for all projects in a single request, of course. We’ll see how we can handle scaling this when we talk about sync engines in the next section.
To update the data in a TanStack DB Collection, we’ll have to use a mutation. These are similar to TanStack Query mutations, but with two main differences: they’re optimistic by default (i.e. they update the local data in the collection first), and they’re transactional, which means they can be rolled back automatically.
Collections support insert
, update
, and delete
mutations, which will call the collection’s onInsert
, onUpdate
, and onDelete
handlers, respectively, to update the data on the backend.
Here’s how our todo collection would look like with an added onUpdate
handler to persist the data in the backend:
const todoCollection = createCollection({
// ... other config
onUpdate: async ({ transaction }) => {
const { original, changes } = transaction.mutations[0];
await fetch(`/api/todos/${original.id}`, {
method: "PATCH",
body: JSON.stringify(changes),
});
},
});
And here’s how we would use it:
// Immediately applies optimistic state
todoCollection.update(todo.id, (draft) => {
draft.completed = true;
});
This is a lot simpler than our TanStack Query optimistic mutation example from the previous section, but it has all of the same features: it updates the UI right away, and it rolls back the updates automatically in case of an error in the request.
Here’s a step-by-step breakdown of how it works:
When we put it all together, we can see how each of TanStack DB’s new primitives is designed to fill a specific gap in TanStack Query:
And one of the nicest things about it is that we can do all of this without changing anything about how our backend layer and APIs are designed.
But we can take things further. So far, we’ve seen how TanStack DB works with a traditional REST API, as a drop-in replacement for Query. But if we want to turn things up to eleven, we have to option of going full-sync.
TanStack DB was designed to work seamlessly with sync engines. If you’re not familiar with sync engines, you can think of them as components that sit between your database and your different clients, making sure your data is always in sync.
If you’ve used web apps like Linear or Figma and noticed the snappy, native, multi-player, real-time feel they have, you’ve experienced the magic of a sync engine in action.
By pairing TanStack DB with a sync engine on the backend, we get some additional benefits, such as:
This last point deserves a few more sentences. We’ve mentioned a couple of times throughout this article how the GET /todos
endpoint our app uses, which returns all todos for all projects, allows us to implement a lot of the features that require filtering and processing the data entirely on the client. But as we’ve also mentioned, this would obviously not scale in a real application where the list could potentially be thousands and thousands of elements long.
This isn’t a problem when using a sync engine, though, where loading megabytes of data upfront is not only acceptable, but a best practice. Sure, the first time a user opens our app they might need to look at a splash screen for a few seconds while the data is downloaded, but on any subsequent requests and visits, they’d only need to download the data that changed.
The specifics of how we could hook up TanStack DB to a sync engine will depend entirely on which sync engine we’re using. As of this writing, TanStack DB natively supports the ElectricSQL and TrailBaze sync engines, but we can expect support for additional engines in the future (Firebase support is being worked on at the moment.)
Let’s say we wanted to bring some of this real-time sync sweetness to our little todo app using the ElectricSQL engine. We’ll need to make a few changes to our app to support this.
A quick note here: ElectricSQL currently only works with Postgres databases, so going forward we will pretend that our todo app was using Postgres all along. (cue the astronaut pointing a gun at the other astronaut meme)
First, on the client, we have to update our Collection definitions to use the electricCollectionOptions
function instead of queryCollectionOptions
:
import { createCollection } from "@tanstack/react-db";
import { electricCollectionOptions } from "@tanstack/electric-db-collection";
export const todoCollection = createCollection(
electricCollectionOptions({
id: "todos",
shapeOptions: {
url: "/api/electric/shape",
params: {
table: "todos",
},
},
getKey: (item) => item.id,
schema: todoSchema,
})
);
The new option here is the shapeOptions
object, which contains the details of the ElectricSQL Shape we’re interested in. The url
attribute could be the location of an Electric server, but in our case, we’re using a proxy endpoint on our app’s backend.
The next step then is to implement this proxy endpoint on our backend. This endpoint will replace our existing GET /todos
endpoint, and will fetch the data from the ElectricSQL engine instead of calling our database directly. It will also handle authentication and authorization.
You can find all the details about this in the ElectricSQL docs, but here’s a high-level, simplified version of what our proxy endpoint looks like:
export async function GET(request: Request) {
const electricUrl = new URL(`http://localhost:3000/v1/shape`);
// Set the table server-side, not from client params
electricUrl.searchParams.set(`table`, `todos`);
// Authentication: verify the user is logged in
const user = await loadUser();
if (!user) {
return new Response(`user not found`, { status: 401 });
}
// Authorization: only fetch the user's data
electricUrl.searchParams.set(`where`, `"user_id" = ${user.id}`);
// Fetch the data from the ElectricSQL engine
return await fetch(electricUrl);
}
Finally, we have to hook up our Postgres database to Electric. Showing how to do this is beyond the scope of this article, but it’s not really that complicated. The easiest way to do this is by using the Electric Cloud platform (all you’ll need is your database’s connection string), but you also have the option of self-hosting Electric, and there’s a handy Docker Compose file that can get you started in just a few minutes.
At a high level, here’s how our app now works with the ElectricSQL sync engine in the mix:
You might have noticed that there’s one thing we didn’t have to change when we adopted the sync engine—our mutations. That might not always be the case, but it’s a nice feature of engines like Electric. They treat the database as the source of truth, so they don’t care about how the data was changed. We can continue using our existing POST
, PATCH
, and DELETE
endpoints to mutate our data. All we have to do is change the way we read it.
As I mentioned in a recent issue of the Frontend at Scale newsletter, the web is about to enter its sync era, and TanStack DB is one of the libraries leading the way.
My favorite thing about TanStack DB is that it doesn’t try to take over our entire frontend and backend layer as many of the alternatives do. It meets us where we are—it can be adopted incrementally, one query + mutation combo at a time, without changing anything on our backend.
And if and when our app is ready for some of that real-time sync magic, adopting a sync engine is only a few steps away.
Thanks to Kyle Mathews for reviewing this article.
Well, there's only one way to find out!
Join 5,000+ developers getting the latest insights from the world of software design and architecture—tailored specifically to frontend engineers. Delivered right to your inbox every two weeks.
“Maxi's newsletter is a treasure trove of wisdom for software engineers with a growth mindset. Packed with valuable insights on software design and curated resources that keep you at the forefront of the industry, it's simply a must-read. Elevate your game, one issue at a time.”