TanStack DB — Supercharge Your App with a Reactive Data Layer


Get the Next.js weekly drop
Free weekly notes on App Router, auth, and frontend stacks that ship. No spam—just what's worth your inbox.
By subscribing you agree to our Privacy Policy.
TanStack DB is a reactive client-side data layer for JavaScript and TypeScript apps, built to handle live queries, real-time sync, and local optimistic writes — all while maintaining blazing-fast performance and consistency in your UI.
Like TanStack Query? You'll love TanStack DB.
Try the entire TanStack ecosystem, including TanStack Query, TanStack Store, and more at https://tanstack.com/.
TanStack DB brings fine-grained reactivity, transactional writes, and sub-millisecond live queries to your frontend — even in large, complex applications. It's designed for:
Built on a TypeScript implementation of differential dataflow, TanStack DB enables:
TanStack DB works by creating collections, subscribing to live queries, and applying transactional writes that keep UI state responsive and in sync.
Let's build a complete blog post management system using TanStack DB. We'll implement:
First, let's define our types and create our post collection:
// types.ts
export interface Post {
id: string;
title: string;
content: string;
authorId: string;
status: 'draft' | 'published';
tags: string[];
createdAt: string;
updatedAt: string;
}
// schema.ts
import { z } from 'zod';
export const postSchema = z.object({
id: z.string(),
title: z.string().min(1),
content: z.string(),
authorId: z.string(),
status: z.enum(['draft', 'published']),
tags: z.array(z.string()),
createdAt: z.string(),
updatedAt: z.string(),
});
// collections/posts.ts
import { createQueryCollection } from "@tanstack/db-collections";
import { Post } from "../types";
import { postSchema } from "../schema";
// API functions
const api = {
posts: {
getAll: async () => {
const response = await fetch('/api/posts');
return response.json();
},
create: async (post: Post) => {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(post),
});
return response.json();
},
update: async (post: Post) => {
const response = await fetch(`/api/posts/${post.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(post),
});
return response.json();
},
delete: async (id: string) => {
await fetch(`/api/posts/${id}`, { method: 'DELETE' });
return { success: true };
}
}
};
// Create the post collection
export const postCollection = createQueryCollection<Post>({
queryKey: ["posts"],
queryFn: api.posts.getAll,
getId: (post) => post.id,
schema: postSchema,
});Now, let's create a component that displays posts with filtering capabilities:
// components/PostList.tsx
import { useLiveQuery } from "@tanstack/react-db";
import { postCollection } from "../collections/posts";
import { useState } from "react";
export const PostList = () => {
const [statusFilter, setStatusFilter] = useState<'all' | 'draft' | 'published'>('all');
// Live query with filtering
const { data: posts, isLoading } = useLiveQuery((query) => {
let baseQuery = query.from({ postCollection });
// Apply filters
if (statusFilter !== 'all') {
baseQuery = baseQuery.where("@status", "=", statusFilter);
}
// Sort by updatedAt in descending order
return baseQuery.orderBy("@updatedAt", "desc");
}, [statusFilter]);
if (isLoading) return <div>Loading posts...</div>;
return (
<div className="space-y-6">
<div className="flex space-x-4">
<button
onClick={() => setStatusFilter('all')}
className={statusFilter === 'all' ? 'font-bold' : ''}
>
All Posts
</button>
<button
onClick={() => setStatusFilter('published')}
className={statusFilter === 'published' ? 'font-bold' : ''}
>
Published
</button>
<button
onClick={() => setStatusFilter('draft')}
className={statusFilter === 'draft' ? 'font-bold' : ''}
>
Drafts
</button>
</div>
<div className="grid gap-4">
{posts?.map(post => (
<div key={post.id} className="border p-4 rounded-lg">
<h3 className="text-xl font-bold">{post.title}</h3>
<div className="flex items-center space-x-2 text-sm text-gray-500">
<span className={`px-2 py-1 rounded ${
post.status === 'published' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
}`}>
{post.status}
</span>
<span>Updated: {new Date(post.updatedAt).toLocaleDateString()}</span>
</div>
<p className="mt-2 line-clamp-2">{post.content}</p>
</div>
))}
</div>
</div>
);
};Let's implement a form for creating and updating posts with optimistic UI updates:
// components/PostForm.tsx
import { useOptimisticMutation } from "@tanstack/react-db";
import { postCollection } from "../collections/posts";
import { Post } from "../types";
import { useState } from "react";
import { v4 as uuid } from "uuid";
interface PostFormProps {
initialPost?: Post;
onSuccess?: () => void;
}
export const PostForm = ({ initialPost, onSuccess }: PostFormProps) => {
const isEditing = !!initialPost;
const [formData, setFormData] = useState<Partial<Post>>(
initialPost || {
title: "",
content: "",
status: "draft",
tags: [],
}
);
// Handle tag input
const [tagInput, setTagInput] = useState("");
const addTag = () => {
if (tagInput.trim() && !formData.tags?.includes(tagInput.trim())) {
setFormData({
...formData,
tags: [...(formData.tags || []), tagInput.trim()]
});
setTagInput("");
}
};
const removeTag = (tag: string) => {
setFormData({
...formData,
tags: formData.tags?.filter(t => t !== tag)
});
};
// Create or update mutation with optimistic updates
const mutation = useOptimisticMutation({
mutationFn: async ({ transaction }) => {
const { collection, modified: postData } = transaction.mutations[0]!;
// Call the appropriate API endpoint
if (isEditing) {
await api.posts.update(postData as Post);
} else {
await api.posts.create(postData as Post);
}
// Invalidate the collection to refresh data
await collection.invalidate();
// Call success callback if provided
onSuccess?.();
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const now = new Date().toISOString();
const postData: Post = {
...(formData as Post),
id: formData.id || uuid(),
authorId: formData.authorId || "current-user-id", // In a real app, get from auth context
createdAt: formData.createdAt || now,
updatedAt: now,
};
mutation.mutate(() => {
if (isEditing) {
return postCollection.update(postData);
} else {
return postCollection.insert(postData);
}
});
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block mb-1">Title</label>
<input
type="text"
value={formData.title || ""}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full p-2 border rounded"
required
/>
</div>
<div>
<label className="block mb-1">Content</label>
<textarea
value={formData.content || ""}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
className="w-full p-2 border rounded min-h-[200px]"
required
/>
</div>
<div>
<label className="block mb-1">Tags</label>
<div className="flex">
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
className="flex-1 p-2 border rounded-l"
placeholder="Add a tag"
/>
<button
type="button"
onClick={addTag}
className="px-4 bg-blue-500 text-white rounded-r"
>
Add
</button>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{formData.tags?.map(tag => (
<span key={tag} className="px-2 py-1 bg-gray-100 rounded-full flex items-center">
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="ml-1 text-red-500"
>
×
</button>
</span>
))}
</div>
</div>
<div>
<label className="block mb-1">Status</label>
<select
value={formData.status || "draft"}
onChange={(e) => setFormData({
...formData,
status: e.target.value as 'draft' | 'published'
})}
className="w-full p-2 border rounded"
>
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
</div>
<button
type="submit"
disabled={mutation.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{mutation.isPending ? "Saving..." : isEditing ? "Update Post" : "Create Post"}
</button>
</form>
);
};Finally, let's create a page that combines our components:
// pages/admin/posts.tsx
import { useState } from "react";
import { PostList } from "../../components/PostList";
import { PostForm } from "../../components/PostForm";
import { Post } from "../../types";
export default function PostsAdminPage() {
const [isCreating, setIsCreating] = useState(false);
const [editingPost, setEditingPost] = useState<Post | null>(null);
const handleCreateSuccess = () => {
setIsCreating(false);
};
const handleUpdateSuccess = () => {
setEditingPost(null);
};
return (
<div className="container mx-auto py-8 px-4">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Blog Post Management</h1>
{!isCreating && !editingPost && (
<button
onClick={() => setIsCreating(true)}
className="px-4 py-2 bg-green-600 text-white rounded"
>
Create New Post
</button>
)}
</div>
{isCreating && (
<div className="mb-8 p-6 border rounded-lg bg-gray-50">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">Create New Post</h2>
<button
onClick={() => setIsCreating(false)}
className="text-gray-500"
>
Cancel
</button>
</div>
<PostForm onSuccess={handleCreateSuccess} />
</div>
)}
{editingPost && (
<div className="mb-8 p-6 border rounded-lg bg-gray-50">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">Edit Post</h2>
<button
onClick={() => setEditingPost(null)}
className="text-gray-500"
>
Cancel
</button>
</div>
<PostForm initialPost={editingPost} onSuccess={handleUpdateSuccess} />
</div>
)}
<PostList onEdit={setEditingPost} />
</div>
);
};Type-safe sets of objects that mirror backend tables or filtered result sets. They're reactive, normalized, and completely local.
Run incrementally updated queries across collections, powered by differential dataflow — no full re-renders required.
Stage and apply local optimistic writes. Batched mutations sync with the backend and roll back automatically when needed.
npm install @tanstack/react-db @tanstack/db-collectionsSupport for other frameworks is coming soon.
Check out the Usage Guide for advanced topics:
How is this different from TanStack Query?
TanStack Query fetches and caches data. TanStack DB builds on it to manage live, reactive local collections and transactional updates.
Does it require a sync engine like ElectricSQL?
No — TanStack DB works with or without one. You can use REST, polling, GraphQL, or any custom logic.
Are queries run on the server?
Nope. TanStack DB queries are client-side only. You control the data sync layer.
Is this an ORM?
No. TanStack DB is not an ORM — it doesn't abstract backend queries. Instead, it gives you powerful primitives to keep local state in sync and reactive.
TanStack DB is in alpha — APIs may change, and bugs are expected. Join the GitHub discussion and help shape the future of frontend data management.
🚧 Stay tuned — TanStack DB is evolving fast. Follow @tannerlinsley and the TanStack repo for updates.