Migrating from TanStack Query¶
Step-by-step guide to migrating from TanStack Query (React Query) to Verity
This guide helps you transition from TanStack Query to Verity while understanding the philosophical differences.
Why Migrate?¶
When Does Migration Make Sense?
Consider Verity if you're experiencing:
- 🔄 Optimistic update rollback complexity
- 🐛 Manual invalidation bugs
- 📱 Need to support multiple clients (web + mobile)
- 🔁 Real-time sync challenges
- 🤔 Confusion about what data is "real"
- ✅ No optimistic updates (honest loading states)
- ✅ Server-authored invalidation contract
- ✅ Multi-client sync via SSE
- ✅ Level conversion planning
- ✅ Explicit truth-state boundary
- ❌ Optimistic UI (by design)
- ❌ Infinite queries helper
- ❌ Mature DevTools (Verity's are simpler)
- ❌ Large community size
Conceptual Mapping¶
Queries → Collections & Types¶
// Register once (at app root)
registry.registerCollection('todos', {
fetch: () => fetch('/api/todos').then(r => r.json())
})
registry.registerType('todo', {
fetch: ({ id }) => fetch(`/api/todos/${id}`).then(r => r.json())
})
// Use in components
const todos = useCollection('todos')
const todo = useItem('todo', id)
Key Difference
Verity: Define fetchers once, use everywhere. TanStack Query: Define per-use.
Mutations → Directives¶
async function createTodo(data) {
const res = await fetch('/api/todos', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-ID': registry.clientId
},
body: JSON.stringify(data)
})
const payload = await res.json()
// Server decides what changed
// payload.directives = [
// { op: 'refresh_collection', name: 'todos' },
// { op: 'refresh_collection', name: 'stats' }
// ]
await registry.applyDirectives(payload.directives)
}
Key Difference
Verity: Server decides invalidation. TanStack Query: You decide.
Optimistic Updates → Honest Loading¶
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// Cancel outgoing refetches
await queryClient.cancelQueries(['todos'])
// Snapshot previous value
const previous = queryClient.getQueryData(['todos'])
// Optimistically update
queryClient.setQueryData(['todos'], old =>
old.map(t => t.id === newTodo.id ? newTodo : t)
)
return { previous }
},
onError: (err, newTodo, context) => {
// Rollback on error
queryClient.setQueryData(['todos'], context.previous)
},
onSettled: () => {
queryClient.invalidateQueries(['todos'])
}
})
Problems:
- Complex rollback logic
- Flicker on error
- Race conditions
- Cache manipulation
async function updateTodo(id, data) {
// Show honest loading state
isUpdating = true
try {
const res = await fetch(`/api/todos/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Client-ID': registry.clientId
},
body: JSON.stringify(data)
})
const { directives } = await res.json()
await registry.applyDirectives(directives)
} finally {
isUpdating = false
}
}
Benefits:
- No rollback needed
- No flicker
- Server confirms truth
- Simple logic
Philosophy Shift
Verity: Show loading states honestly. TanStack Query: Speculate and rollback.
Step-by-Step Migration¶
Step 1: Install Verity¶
Step 2: Set Up Registry¶
Create src/verity.js:
import { createRegistry } from 'verity-dl'
export const registry = createRegistry({
sse: {
url: '/api/events',
audience: 'global'
},
memory: {
maxItemsPerType: 1000,
itemTtlMs: 15 * 60 * 1000
}
})
// Register all your types and collections here
Step 3: Register Collections¶
// In src/verity.js (once)
registry.registerCollection('todos', {
fetch: () => fetch('/api/todos').then(r => r.json()),
stalenessMs: 60_000
})
// In components (use anywhere)
function TodoList() {
const todos = useCollection('todos')
}
function OtherComponent() {
const todos = useCollection('todos') // Same instance
}
Step 4: Register Types¶
Step 5: Convert Parameterized Queries¶
// In src/verity.js
registry.registerCollection('todos', {
fetch: async (params = {}) => {
const url = new URL('/api/todos', location.origin)
if (params.status) url.searchParams.set('status', params.status)
return fetch(url).then(r => r.json())
}
})
// In component
const todos = useCollection('todos', { status: 'active' })
Step 6: Update Mutations¶
const [isCreating, setIsCreating] = useState(false)
const handleCreate = async () => {
setIsCreating(true)
try {
const res = await fetch('/api/todos', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-ID': registry.clientId
},
body: JSON.stringify({ title: 'New todo' })
})
const { directives } = await res.json()
await registry.applyDirectives(directives)
} finally {
setIsCreating(false)
}
}
Step 7: Update Backend to Return Directives¶
@app.post('/api/todos')
def create_todo():
todo = Todo(title=request.json['title'])
db.session.add(todo)
db.session.commit()
directives = [
{ 'op': 'refresh_collection', 'name': 'todos' }
]
emit_directives(directives, source=request.headers.get('X-Client-ID'))
return {
'todo': todo.to_dict(),
'directives': directives
}
Step 8: Set Up SSE Endpoint¶
from flask import Response
import json
import time
subscribers = []
def emit_directives(directives, source=None):
"""Emit directives to all SSE subscribers"""
payload = {
'type': 'directives',
'directives': directives,
'source': source,
'seq': get_next_sequence()
}
for subscriber in subscribers:
subscriber.put(json.dumps(payload))
@app.get('/api/events')
def sse_stream():
"""SSE endpoint for real-time directive broadcast"""
def generate():
import queue
q = queue.Queue()
subscribers.append(q)
try:
# Send keepalive every 30s
while True:
try:
msg = q.get(timeout=30)
yield f"data: {msg}\n\n"
except queue.Empty:
yield ": keepalive\n\n"
finally:
subscribers.remove(q)
return Response(generate(), mimetype='text/event-stream')
Common Patterns¶
Pattern: Dependent Queries¶
Pattern: Infinite Queries¶
// Use parameterized collections
const [page, setPage] = useState(1)
const [allItems, setAllItems] = useState([])
const todos = useCollection('todos', { page })
useEffect(() => {
if (todos.state.items.length) {
setAllItems(prev => [...prev, ...todos.state.items])
}
}, [todos.state.items])
const loadMore = () => {
if (todos.state.meta.hasMore) setPage(p => p + 1)
}
Alternative
For true infinite scroll, consider loading all data at once or using cursor-based pagination.
Pattern: Prefetching¶
Troubleshooting¶
Common Migration Issues
Issue: Flicker During Migration¶
Problem: Both libraries refetching simultaneously
Solution: Migrate route-by-route or component-by-component
// Wrap in feature flag
const USE_VERITY = true
function TodoList() {
if (USE_VERITY) {
const todos = useCollection('todos')
// Verity rendering
} else {
const { data: todos } = useQuery(['todos'], fetchTodos)
// TanStack Query rendering
}
}
Issue: Missing Invalidations¶
Problem: Forgot to add directives to backend
Solution: Audit all mutations
Issue: Optimistic UI Feels Slow¶
Problem: Adjustment period from instant → honest
Solution:
- Make servers respond faster
- Use good skeletons
- Show subtle spinners
- Trust builds over time
Complete Example¶
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
function TodoApp() {
const queryClient = useQueryClient()
const { data: todos, isLoading } = useQuery({
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then(r => r.json())
})
const createMutation = useMutation({
mutationFn: (data) => fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(data)
}),
onMutate: async (newTodo) => {
await queryClient.cancelQueries(['todos'])
const previous = queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], old => [...old, newTodo])
return { previous }
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previous)
},
onSettled: () => {
queryClient.invalidateQueries(['todos'])
}
})
return (
<div>
{isLoading ? <p>Loading...</p> : (
<ul>
{todos.map(todo => <li key={todo.id}>{todo.title}</li>)}
</ul>
)}
<button onClick={() => createMutation.mutate({ title: 'New' })}>
Add Todo
</button>
</div>
)
}
import { useCollection } from 'verity-dl/adapters/react'
import { registry } from './verity'
function TodoApp() {
const todos = useCollection('todos')
const [isCreating, setIsCreating] = useState(false)
const createTodo = async () => {
setIsCreating(true)
try {
const res = await fetch('/api/todos', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-ID': registry.clientId
},
body: JSON.stringify({ title: 'New' })
})
const { directives } = await res.json()
await registry.applyDirectives(directives)
} finally {
setIsCreating(false)
}
}
return (
<div>
{todos.state.loading ? <p>Loading...</p> : (
<ul>
{todos.state.items.map(todo => <li key={todo.id}>{todo.title}</li>)}
</ul>
)}
<button onClick={createTodo} disabled={isCreating}>
{isCreating ? 'Adding...' : 'Add Todo'}
</button>
</div>
)
}
Checklist¶
Migration Checklist
- Install Verity
- Create registry setup file
- Register all collections
- Register all types
- Convert queries to useCollection/useItem
- Convert mutations to directive-based
- Update backend to return directives
- Set up SSE endpoint
- Remove optimistic updates
- Test multi-client sync
- Remove TanStack Query