Replace React Query's server state management with TanStack DB's client-side database.
// 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])
},
})
}
// 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),
}
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 }))
}
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,
}))
)
}
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.
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)
}
// 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 ... */
},
})
// 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
}
},
}
npm install @tanstack/react-db @tanstack/query-db-collection zod
// 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()),
// ...
})
// 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
// ensureQueryData -> Direct collection access
queryClient.ensureQueryData -> collection.get() or collection.query()
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 |
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.