Skip to content

Instantly share code, notes, and snippets.

@alavkx
Last active September 12, 2025 17:15
Show Gist options
  • Save alavkx/b93808587ebc62cc2fe7b1eb2bdce6f6 to your computer and use it in GitHub Desktop.
Save alavkx/b93808587ebc62cc2fe7b1eb2bdce6f6 to your computer and use it in GitHub Desktop.
@tanstack/query --> @tanstack/db React Migration Guide

Migration Guide: React Query → TanStack DB

Migration Pattern

Replace React Query's server state management with TanStack DB's client-side database.

BEFORE: React Query

// api/posts.ts
export const postsApi = {
  getAll: async (): Promise<Post[]> => fetch('/api/posts').then(r => r.json()),
  create: async (input: CreatePostInput): Promise => /* ... */,
  update: async (id: string, input: UpdatePostInput): Promise => /* ... */,
  delete: async (id: string): Promise => /* ... */
}

// hooks/usePosts.ts
export function usePosts() {
  return useQuery({
    queryKey: ['posts'],
    queryFn: postsApi.getAll,
  })
}

export function useActivePosts() {
  return useQuery({
    queryKey: ['posts'],
    queryFn: postsApi.getAll,
    select: (posts) => posts.filter(post => post.status === 'published')
  })
}

export function useCreatePost() {
  const queryClient = useQueryClient()
  return useMutation({
    mutationFn: postsApi.create,
    onSuccess: (newPost) => {
      queryClient.invalidateQueries({ queryKey: ['posts'] })
      queryClient.setQueryData(['posts'], (old: Post[]) => [...old, newPost])
    },
  })
}

AFTER: TanStack DB

// schemas/posts.ts
export const postSchema = z.object({
  id: z.string().uuid().default(() => crypto.randomUUID()),
  title: z.string(),
  content: z.string(),
  status: z.enum(['draft', 'published']),
  created_at: z.string().datetime().default(() => new Date().toISOString()),
})

// collections/posts.ts
export const postsCollection = createCollection(
  queryCollectionOptions({
    id: "posts",
    queryKey: ["posts"],
    refetchInterval: 30000,
    queryFn: async () => fetch('/api/posts').then(r => r.json()),
    queryClient: new QueryClient(),
    schema: postSchema,
    getKey: (item) => item.id,
    onInsert: async ({ transaction }) => {
      // Note: transaction.mutations can contain multiple bundled mutations
      const { modified: newPost } = transaction.mutations[0]
      /* ... POST to /api/posts ... */
    },
    onUpdate: async ({ transaction }) => {
      const { modified: updatedPost } = transaction.mutations[0]
      /* ... PATCH to /api/posts/${updatedPost.id} ... */
    },
    onDelete: async ({ transaction }) => {
      const { original: deletedPost } = transaction.mutations[0]
      /* ... DELETE to /api/posts/${deletedPost.id} ... */
    },
  })
)

// hooks/usePosts.ts
export function usePosts(filters = {}) {
  return useLiveQuery(
    (q) => {
      let query = q.from({ posts: postsCollection })
      if (filters.search) {
        query = query.where(({ posts }) =>
          ilike(posts.title, `%${filters.search}%`)
        )
      }
      return query.orderBy(({ posts }) => desc(posts.created_at))
    },
    [filters]
  )
}

export const postOperations = {
  create: (input) => postsCollection.insert(input), // Uses schema defaults
  update: (id, input) => postsCollection.update(id, (draft) =>
    Object.assign(draft, input)
  ),
  delete: (id) => postsCollection.delete(id),
}

Replacing select Option

Reusable Collection Views

For frequently used transformations, create separate collections:

// collections/active-users.ts  
const activeUsers = createCollection(liveQueryCollectionOptions({
  query: (q) =>
    q
      .from({ user: usersCollection })
      .where(({ user }) => eq(user.active, true))
      .select(({ user }) => ({
        id: user.id,
        name: user.name,
        email: user.email,
      }))
}))

// Usage
export function useActiveUsers() {
  return useLiveQuery((q) => q.from({ activeUsers }))
}

Inline Transformations

For one-off use cases, query directly:

export function useActiveUsers() {
  return useLiveQuery((q) =>
    q
      .from({ user: usersCollection })
      .where(({ user }) => eq(user.active, true))
      .select(({ user }) => ({
        id: user.id,
        name: user.name,
        email: user.email,
      }))
  )
}

Replacing ensureQueryData

Replace imperative data fetching with direct collection queries:

// BEFORE: React Query
const userData = await queryClient.ensureQueryData({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId)
})

// AFTER: TanStack DB
const userData = usersCollection.get(userId)
// or for more complex queries
const userData = usersCollection.query((q) => 
  q.where(({ user }) => eq(user.id, userId))
)

Remember: You're querying collections like database tables, because they are database tables.

Collection Preloading

React Router Loaders

Preload collections in route loaders without blocking the UI:

// routes/root.tsx
export async function loader() {
  // Kick off preloads without awaiting
  usersCollection.preload()
  postsCollection.preload()
  return Promise.resolve(null)
}

Error Handling Updates

Collection Error Handling

// collections/posts.ts
import { SchemaValidationError, CollectionInErrorStateError } from "@tanstack/react-db"

export const postsCollection = createCollection({
  // ... config
  retry: true, // Keep retrying on fetch errors
  onInsert: async ({ transaction }) => {
    // transaction.mutations can contain multiple bundled operations
    const { modified: newPost } = transaction.mutations[0]
    /* ... sync to backend, throw on error for rollback ... */
  },
})

Component Error Handling

// hooks/usePosts.ts
export function usePosts() {
  const { data, status, isError, isLoading } = useLiveQuery(
    (q) => q.from({ posts: postsCollection })
  )

  const handleRecovery = async () => {
    if (postsCollection.status === "error") {
      await postsCollection.cleanup()
      postsCollection.preload()
    }
  }

  return { data, status, isError, isLoading, handleRecovery }
}

// Operations with error handling
export const postOperations = {
  create: async (input) => {
    try {
      const tx = await postsCollection.insert(input)
      await tx.isPersisted.promise
    } catch (error) {
      if (error instanceof SchemaValidationError) {
        throw new Error(`Validation: ${error.issues[0]?.message}`)
      }
      if (error instanceof CollectionInErrorStateError) {
        await postsCollection.cleanup()
        return postOperations.create(input)
      }
      throw error
    }
  },
}

Migration Steps

1. Dependencies

npm install @tanstack/react-db @tanstack/query-db-collection zod

2. Schema Migration

// Convert interfaces to Zod schemas with defaults
interface Post -> const postSchema = z.object({
  id: z.string().uuid().default(() => crypto.randomUUID()),
  created_at: z.string().datetime().default(() => new Date().toISOString()),
  // ...
})

3. Hook Transformation

// useQuery -> useLiveQuery
useQuery({ queryKey, queryFn }) -> useLiveQuery((q) => q.from({ collection }))

// useMutation -> Direct operations
useMutation({ mutationFn }) -> collection.insert/update/delete()

// select option -> inline queries or separate collections
useQuery({ select }) -> useLiveQuery with .select() or separate collection

4. Data Access Updates

// ensureQueryData -> Direct collection access
queryClient.ensureQueryData -> collection.get() or collection.query()

Key Transformations

React Query TanStack DB
useQuery() useLiveQuery()
useMutation() collection.insert/update/delete()
select option .select() in query or separate collection
ensureQueryData() collection.get() or collection.query()
onSuccess onInsert/onUpdate/onDelete
queryClient.invalidateQueries() Automatic
Manual defaults Zod schema defaults
Query in views Route loader preloads

Validation

After migration verify:

  • ✅ No loading states after preload
  • ✅ Instant UI updates
  • ✅ Automatic backend sync
  • ✅ Proper error rollback
  • ✅ Schema defaults work
  • ✅ Case-insensitive search

Apply this pattern systematically to each entity for consistent migration.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment