How to use TanStack DB to build reactive, offline-ready React apps

React developers have spent years wrestling with state management complexity. Redux adds boilerplate, and useContext quickly gets messy as apps scale. TanStack DB flips this model: it’s a reactive, client-side database with local-first sync, offline support, and optimistic UX that updates instantly, no waiting for server responses.

Unlike TanStack Query (great for server state), Convex (which ties you to its backend), or traditional state management, TanStack DB lets you think in terms of data and queries. You get SQL-like syntax, reactive live queries, and automatic optimistic mutations, all with minimal code.

In this tutorial, we’ll build a task management app to show how TanStack DB delivers reactive state, optimistic updates, and offline-ready UX with surprising simplicity.

To follow along, you will need the following:

  • Node.js v18+ installed on your machine
  • React v18+ with TypeScript
  • Basic understanding of React

Before TanStack DB

Traditionally, managing reactive client state in React looked something like this:

import React, { useState, useEffect } from 'react';
function TaskList() {
  const [tasks, setTasks] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchTasks = async () => {
      try {
        const response = await fetch('/api/tasks');
        if (!response.ok) {
          throw new Error('Failed to fetch tasks');
        }
        const data = await response.json();
        setTasks(data);
      } catch (e) {
        setError(e);
      } finally {
        setLoading(false);
      }
    };
    fetchTasks();
  }, []);

  const addTask = async (newTask) => {
    setLoading(true);
    try {
      const response = await fetch('/api/tasks', {
        method: 'POST',
        body: JSON.stringify(newTask),
      });
      const task = await response.json();
      setTasks(prev => [...prev, task]);
    } catch (e) {
      setError(e);
    } finally {
      setLoading(false);
    }
  };

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      {tasks.map(task => (
        <div key={task.id}>{task.title}</div>
      ))}
    </div>
  );
}

This approach comes with serious drawbacks. It requires excessive boilerplate just to fetch data, with multiple state variables and manual loading flags scattered across your app. Error handling means repetitive try–catch blocks in every component. There are no optimistic updates, so users wait on the server before seeing changes. And without offline support, the app fails entirely when the network is unavailable.

With TanStack DB

Now let’s see how TanStack DB eliminates this complexity.

With TanStack DB, you don’t need to manually manage loading states, error handling, or data synchronization. The reactive queries handle everything automatically:

import { useLiveQuery } from '@tanstack/react-db';
import { taskCollection } from '@/lib/collections';

function TaskList() {
  const { data: tasks = [] } = useLiveQuery((q) =>
    q.from({ task: taskCollection }).select(({ task }) => task)
  );

  const addTask = (newTask) => {
    taskCollection.insert(newTask);
  };

  return (
    <div>
      {tasks.map(task => (
        <div key={task.id}>{task.title}</div>
      ))}
    </div>
  );
}

Did you notice the absence of useState, useEffect, and loading state management?

With TanStack DB, you can handle data fetching, mutations, and reactive updates declaratively.

Understanding TanStack DB’s core concepts

Before we dive into building our app, let’s understand the key concepts that make TanStack DB so powerful. Once you understand these concepts, the code we’re about to write will make more sense.

What is a collection?

Think of a collection as a normalized client-side cache that holds a typed set of objects. For example:

  • A users collection holds user objects
  • A todos collection holds todo objects

  • A posts collection holds blog post objects

Each collection is like a table in your client-side database:

const taskCollection = createCollection(/* config */)
const userCollection = createCollection(/* config */)

Collections have four powerful characteristics. They are type-safe, enforcing schemas that prevent invalid data. They’re normalized, ensuring no duplicates since every object has a unique key. They’re reactive, triggering component re-renders whenever data changes. And they’re local-first, storing data in memory for instant access without network delays.

Live queries (SQL-flavoured data reading)

Live queries are the magic sauce of TanStack DB. They work like SQL queries but with real-time updates. This query automatically updates when tasks change:

const { data: tasks } = useLiveQuery((q) =>
  q
    .from({ task: taskCollection })                 // FROM tasks
    .where(({ task }) => task.status === 'pending') // WHERE status = 'pending'
    .orderBy(({ task }) => task.createdAt, 'desc')  // ORDER BY created_at DESC
    .select(({ task }) => task)                     // SELECT *
)

Live queries provide automatic updates where the query re-runs when underlying data changes, eliminating the need for manual subscriptions. They use SQL-like syntax with familiar .from(), .where(), and .select() methods. They support joins that can combine data from multiple collections, and they’re highly performant because they only re-run when relevant data actually changes, not on every render.

Optimistic mutations: Instant UI feedback

Traditional apps wait for server responses before updating the UI. TanStack DB updates the UI instantly (optimistic UI):

// Traditional approach: Wait for server response
const [loading, setLoading] = useState(false)
const updateTask = async () => {
  setLoading(true)
  await fetch('/api/tasks/1', { method: 'PUT', /* ... */ })
  setLoading(false)
  // Then update UI
}

// TanStack DB approach: Update UI instantly
const updateTask = () => {
  taskCollection.update(taskId, (draft) => {
    draft.status = 'completed'  // UI updates immediately
  })
}

Optimistic mutations improve UX with instant feedback, make apps feel native and responsive, and can roll back changes safely if a request fails. They also handle concurrent updates gracefully when multiple users or tabs modify the same data.

Internal engine: Differential dataflow (D2S)

Under the hood, TanStack DB uses a sophisticated engine called differential dataflow (D2S). You don’t need to understand the internals, but here’s why it matters:

The D2S engine handles incremental updates by only re-computing what actually changed, rather than recalculating everything from scratch. It enables efficient queries through smart caching and memoization, delivers real-time reactivity with instant propagation of changes, and maintains memory efficiency even with large datasets.

Now that you understand these concepts, the code we’re about to write will make much more sense. Let’s build our task management app!

Project setup

Let’s build a task management app that showcases TanStack DB’s reactive power.

Open your terminal and run:

npm create next-app@latest tanstack-db-demo --typescript --tailwind --app
cd tanstack-db-demo

Next, install TanStack DB and its dependencies:

npm install @tanstack/react-db @tanstack/react-query zod uuid
npm install --save-dev @types/uuid

We’ll use Zod for schema validation and UUID for generating IDs.

Building the demo app

The task management app includes task creation with instant form submission and validation, live filtering that updates in real time as you switch between All, Pending, In Progress, and Completed, and optimistic status updates where changing a dropdown updates the UI immediately.

Now, let’s take a look at TanStack DB in action.

Setting up the schema

First, we need to define our data structure. In src/lib, create schema.ts and paste the code below:

import { z } from 'zod'

export const taskSchema = z.object({
  id: z.string(),
  title: z.string().min(1, 'Title is required'),
  description: z.string().optional(),
  status: z.enum(['pending', 'in-progress', 'completed']).default('pending'),
  priority: z.enum(['low', 'medium', 'high']).default('medium'),
  tags: z.array(z.string()).default([]),
  createdAt: z.date().default(() => new Date()),
  updatedAt: z.date().default(() => new Date()),
})

export type Task = z.infer<typeof taskSchema>

The taskSchema is for type safety and validation. Zod validates data at runtime, catches invalid data before it enters your app, and automatically generates TypeScript types, perfect for TanStack DB’s schema system.

Add a category schema for organizing tasks:

export const categorySchema = z.object({
  id: z.string(),
  name: z.string().min(1, 'Category name is required'),
  color: z.string().default('#3B82F6'),
  description: z.string().optional(),
  createdAt: z.date().default(() => new Date()),
  updatedAt: z.date().default(() => new Date()),
})

export type Category = z.infer<typeof categorySchema>

Creating collections

Now, let’s create our data collection. Create collections.ts in the same folder and add the following:

import { createCollection, localOnlyCollectionOptions } from '@tanstack/react-db'
import { taskSchema, type Task } from './schema'

createCollection creates a TanStack DB collection. localOnlyCollectionOptions configures a client-side only collection: in-memory storage for instant access, instant updates with no network delays, offline by default, and optimistic mutations without manual setup.

Next, add the collection configuration:

export const taskCollection = createCollection(localOnlyCollectionOptions({
  id: 'tasks',
  getKey: (item) => item.id,
  schema: taskSchema,
}))

This creates the task collection with local-only options. The getKey function tells TanStack DB how to identify each item. When you update or delete an item, TanStack DB uses this key to find the right record.

Now let’s add some initial data to work with:

const initialTasks: Task[] = [
  {
    id: '1',
    title: 'Learn TanStack DB',
    description: 'Build a demo app with reactive queries',
    status: 'in-progress',
    priority: 'high',
    tags: ['learning', 'react'],
    createdAt: new Date('2024-01-01'),
    updatedAt: new Date('2024-01-01'),
  },
  {
    id: '2',
    title: 'Write tutorial',
    description: 'Document the TanStack DB patterns',
    status: 'pending',
    priority: 'medium',
    tags: ['writing', 'documentation'],
    createdAt: new Date('2024-01-02'),
    updatedAt: new Date('2024-01-02'),
  },
]

// Insert initial data
initialTasks.forEach(task => {
  taskCollection.insert(task)
})

The insertion happens immediately — no async/await — because it’s local-only.

Next, we’ll create React components to display and interact with this data using live queries.

Displaying tasks with live queries

Now comes the magic: displaying our data with reactive queries. Create src/components/TaskList.tsx.

First, add the imports and basic setup:

'use client'
import { useLiveQuery } from '@tanstack/react-db'
import { taskCollection } from '@/lib/collections'
import { useState } from 'react'

export function TaskList() {
  const [filter, setFilter] =
    useState<'all' | 'pending' | 'in-progress' | 'completed'>('all')
}

useLiveQuery is the heart of TanStack DB’s reactivity. Unlike regular useQuery, this hook automatically re-runs whenever the underlying collection data changes.

Now let’s add the live query:

const { data: tasks = [], isLoading } = useLiveQuery((q) => {
  let query = q.from({ task: taskCollection })

  if (filter !== 'all') {
    query = query.where(({ task }) => task.status === filter)
  }

  return query.select(({ task }) => task)
})

q.from({ task: taskCollection }) starts a query from our task collection. The object alias lets you reference the collection as task. The hook returns { data, isLoading, error }.

const { data: categories = [] } = useLiveQuery(
  (q) => q
    .from({ category: categoryCollection })
    .select(({ category }) => ({
      id: category.id,
      name: category.name,
      color: category.color,
    }))
)

Next, let’s add the function to update task status:

const toggleStatus = (
  taskId: string,
  newStatus: 'pending' | 'in-progress' | 'completed'
) => {
  taskCollection.update(taskId, (draft) => {
    draft.status = newStatus
    draft.updatedAt = new Date()
  })
}

This updates an existing item using an Immer-style draft. When you call taskCollection.update(), the UI updates instantly. Any component using useLiveQuery that includes this task will re-render with the new data.

Next, add the function to filter tasks by status and priority:

const filteredTasks = tasks.filter(task => {
  const statusMatch = filter === 'all' || task.status === filter
  const priorityMatch = priorityFilter === 'all' || task.priority === priorityFilter
  return statusMatch && priorityMatch
})

Now let’s build the UI. First, the select filter:

return (
  <div className="space-y-4">
    {/* Filter selects */}
    <div className="flex justify-between items-center">
      <h2 className="text-2xl font-bold text-gray-900">Tasks</h2>
      <div className="flex gap-2">
        <select
          value={filter}
          onChange={(e) =>
            setFilter(e.target.value as 'all' | 'pending' | 'in-progress' | 'completed')
          }
          className="px-3 text-black py-2 border border-gray-300 rounded-md text-sm"
        >
          <option value="all">All Status</option>
          <option value="pending">Pending</option>
          <option value="in-progress">In Progress</option>
          <option value="completed">Completed</option>
        </select>

        <select
          value={priorityFilter}
          onChange={(e) =>
            setPriorityFilter(e.target.value as 'all' | 'low' | 'medium' | 'high')
          }
          className="px-3 text-black py-2 border border-gray-300 rounded-md text-sm"
        >
          <option value="all">All Priorities</option>
          <option value="low">Low</option>
          <option value="medium">Medium</option>
          <option value="high">High</option>
        </select>
      </div>
    </div>

The TypeScript assertion ensures the filter value is treated as a literal type, not just a string, for better type checking.

Loading UI:

{isLoading ? (
  <div className="bg-white shadow rounded-lg p-6">
    <div className="animate-pulse">
      <div className="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
      <div className="space-y-3">
        {[1, 2, 3].map((i) => (
          <div key={i} className="h-12 bg-gray-200 rounded"></div>
        ))}
      </div>
    </div>
  </div>
) :

Render the task list:

(
  <div className="bg-white shadow rounded-lg">
    <div className="px-6 py-4 border-b border-gray-200">
      <div className="flex justify-between items-center">
        <h3 className="text-lg font-medium text-gray-900">
          {filteredTasks.length} task{filteredTasks.length !== 1 ? 's' : ''}
        </h3>
        {filter === 'all' && (
          <button
            onClick={bulkCompleteTasks}
            className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
          >
            Complete All Pending
          </button>
        )}
      </div>
    </div>

    {/* Task list */}
    <div className="divide-y divide-gray-200">
      {filteredTasks.length === 0 ? (
        <div className="px-6 py-8 text-center text-gray-500">
          No tasks found. Create your first task to get started!
        </div>
      ) : (
        filteredTasks.map((task) => (
          <div key={task.id} className="px-6 py-4 hover:bg-gray-50">
            <div className="flex items-center justify-between">
              <div className="flex-1">
                <div className="flex items-center gap-3">
                  <h4 className="text-lg font-medium text-gray-900">
                    {task.title}
                  </h4>
                  <span className={`px-2 py-1 text-xs font-medium rounded-full ${
                    task.priority === 'high' ? 'bg-red-100 text-red-800' :
                    task.priority === 'medium' ? 'bg-yellow-100 text-yellow-800' :
                    'bg-green-100 text-green-800'
                  }`}>
                    {task.priority}
                  </span>
                </div>

                {task.description && (
                  <p className="mt-1 text-sm text-gray-600">{task.description}</p>
                )}

                <div className="mt-2 flex items-center gap-4 text-sm text-gray-500">
                  <span>Created: {formatDate(task.createdAt)}</span>
                  {task.dueDate && (
                    <span>Due: {formatDate(task.dueDate)}</span>
                  )}
                </div>

                {/* Category and Tags */}
                <div className="mt-3 flex items-center gap-3">
                  {/* Category Badge */}
                  {(() => {
                    const category = categories.find(cat => cat.id === task.categoryId)
                    const categoryName = category?.name || 'Uncategorized'
                    const categoryColor = category?.color || '#6B7280'

                    return (
                      <span
                        className="px-2 py-1 text-xs font-medium rounded-full"
                        style={{
                          backgroundColor: `${categoryColor}20`,
                          color: categoryColor,
                          border: `1px solid ${categoryColor}40`
                        }}
                      >
                        📁 {categoryName}
                      </span>
                    )
                  })()}

                  {/* Tags */}
                  {task.tags && task.tags.length > 0 && (
                    <div className="flex items-center gap-1">
                      <span className="text-xs text-gray-500">🏷</span>
                      {task.tags.map((tag, index) => (
                        <span
                          key={index}
                          className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded-full border border-gray-200"
                        >
                          {tag}
                        </span>
                      ))}
                    </div>
                  )}
                </div>
              </div>

              <div className="flex items-center gap-2">
                <select
                  value={task.status}
                  onChange={(e) => toggleTaskStatus(
                    task.id,
                    e.target.value as 'pending' | 'in-progress' | 'completed'
                  )}
                  className="px-3 text-black py-1 text-sm border border-gray-300 rounded-md"
                >
                  <option value="pending">Pending</option>
                  <option value="in-progress">In Progress</option>
                  <option value="completed">Completed</option>
                </select>

                <button
                  onClick={() => deleteTask(task.id)}
                  className="px-3 py-1 text-sm text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors"
                >
                  Delete
                </button>
              </div>
            </div>
          </div>
        ))
      )}
    </div>
  </div>
)}
</div>
)
}

Since tasks come from our live query, this list automatically updates when tasks change.

Notice how you never manually update React state. You update the TanStack DB collection, and the UI updates automatically through the live query.

First, set up the providers to use your component. Update your app/page.tsx temporarily:

import { TaskList } from '@/components/TaskList'

export default function Home() {
  return (
    <div className="min-h-screen bg-gray-50">
      {/* Main Content */}
      <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
        <div className="bg-white rounded-lg shadow-sm border border-gray-200">
          <div className="p-6 border-b border-gray-200">
            <div className="flex justify-between items-center">
              <h2 className="text-xl font-semibold text-gray-900">Task Management</h2>
              <button
                onClick={handleCreateTask}
                className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 transition-colors"
              >
                + Create Task
              </button>
            </div>
          </div>
          <div className="p-6">
            <TaskList />
          </div>
        </div>
      </main>
    </div>
  )
}

Setting up providers

We’ll add the providers to app/layout.tsx, then create and configure the query client:

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import './globals.css'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      gcTime: 1000 * 60 * 10,
    },
  },
})

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <QueryClientProvider client={queryClient}>
          {children}
        </QueryClientProvider>
      </body>
    </html>
  )
}

TanStack DB is built on top of TanStack Query. While you don’t directly use query functions, DB uses Query’s caching and synchronization mechanisms under the hood.

gcTime (garbage collection time) controls how long unused query results stay in memory before being cleaned up.

Run npm run dev and you should see the following:

Notice how the tasks filter instantly when you click “Pending”, “In Progress”, or “Completed”.

Delete operation

Performing a delete operation with TanStack DB is as simple as this:

const deleteTask = (taskId: string) => {
  taskCollection.delete(taskId)
}

Bulk operations

Get all pending tasks and update them at once:

const completeAllPending = () => {
  const { data: pendingTasks } = useLiveQuery((q) =>
    q
      .from({ task: taskCollection })
      .where(({ task }) => task.status === 'pending')
      .select(({ task }) => task)
  )

  pendingTasks?.forEach((task) => {
    taskCollection.update(task.id, (draft) => {
      draft.status = 'completed'
      draft.updatedAt = new Date()
    })
  })
}

Complex queries

Filtering:

const { data: highPriorityTasks } = useLiveQuery((q) =>
  q
    .from({ task: taskCollection })
    .where(({ task }) => task.priority === 'high' && task.status !== 'completed')
    .orderBy(({ task }) => task.createdAt, 'desc')
    .select(({ task }) => task)
)

Joins:

const { data: tasksWithCategories } = useLiveQuery((q) =>
  q
    .from({ task: taskCollection })
    .leftJoin(
      { category: categoryCollection },
      ({ task, category }) => task.categoryId === category?.id
    )
    .where(({ task }) => task.status === 'in-progress')
    .select(({ task, category }) => ({
      ...task,
      categoryName: category?.name || 'Uncategorized',
      categoryColor: category?.color || '#6B7280'
    }))
)

This query joins tasks with their categories.

Optimistic mutations

Let’s add the ability to create new tasks with instant UI feedback. Create TaskForm.tsx in the components folder.

Start with imports and state:

'use client'
import { useState } from 'react'
import { taskCollection } from '@/lib/collections'
import { v4 as uuidv4 } from 'uuid'

export function TaskForm() {
  const [title, setTitle] = useState('')
  const [description, setDescription] = useState('')

Since we’re working locally, we’ll create our own IDs with uuidv4 rather than relying on a server.

Form submission handler:

const handleSubmit = (e: React.FormEvent) => {
  e.preventDefault()

  if (!title.trim()) return

  const newTask = {
    id: uuidv4(),
    title: title.trim(),
    description: description.trim() || undefined,
    status: 'pending' as const,
    priority: 'medium' as const,
    tags: [],
    createdAt: new Date(),
    updatedAt: new Date(),
  }

  // Optimistic update - UI updates instantly
  taskCollection.insert(newTask)

  // Reset form
  setTitle('')
  setDescription('')
}

taskCollection.insert(newTask) immediately adds the task to the collection, and the UI reflects it instantly.

Form UI:

return (
  <form onSubmit={handleSubmit} className="space-y-4 mb-6">
    <div>
      <input
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Task title..."
        className="w-full px-3 py-2 border rounded-lg"
        required
      />
    </div>

    <div>
      <textarea
        value={description}
        onChange={(e) => setDescription(e.target.value)}
        placeholder="Task description (optional)..."
        className="w-full px-3 py-2 border rounded-lg"
        rows={3}
      />
    </div>

    <button
      type="submit"
      className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
    >
      Add Task
    </button>
  </form>
)

When you submit, a new task is inserted into TanStack DB and the UI updates instantly — no extra loading state or manual syncing required. If you later wire this to a server, TanStack DB can roll back on failure; for local-only collections, inserts always succeed.

Update your app/page.tsx to include both components:

'use client'
import { useState } from 'react'
import { TaskList } from '@/components/TaskList'
import { TaskForm } from '@/components/TaskForm'

export default function Home() {
  const [showTaskForm, setShowTaskForm] = useState(false)

  const handleCreateTask = () => {
    setShowTaskForm(true)
  }

  const handleCloseTaskForm = () => {
    setShowTaskForm(false)
  }

  return (
    <div className="min-h-screen bg-gray-50">
      <main>...</main>

      {showTaskForm && (
        <TaskForm
          onClose={handleCloseTaskForm}
        />
      )}
    </div>
  )
}

 

You can find the code for the final build on GitHub.

Conclusion

In this tutorial, we explored TanStack DB by building a reactive, local-first task management app. TanStack DB shines in offline-friendly and low-latency apps where users expect instant feedback. If you’re building beyond simple forms or read-only content, and want a UX that feels as smooth as native desktop software, TanStack DB delivers that experience with remarkably little code.

If you encounter any issues while following this tutorial or need expert help with web/mobile development, don’t hesitate to reach out on LinkedIn. I’d love to connect and help out!

The post How to use TanStack DB to build reactive, offline-ready React apps appeared first on LogRocket Blog.

 

This post first appeared on Read More