Query Collection

Query Collection

Query-Sammlungen bieten eine nahtlose Integration zwischen TanStack DB und TanStack Query und ermöglichen die automatische Synchronisierung zwischen Ihrer lokalen Datenbank und entfernten Datenquellen.

Übersicht

Das Paket @tanstack/query-db-collection ermöglicht es Ihnen, Sammlungen zu erstellen, die

  • Automatisch mit entfernten Daten über TanStack Query synchronisiert werden
  • Optimistische Updates mit automatischem Rollback bei Fehlern unterstützen
  • Persistenz durch anpassbare Mutations-Handler handhaben
  • Direkte Schreibfunktionen zum direkten Schreiben in den synchronisierten Datenspeicher bereitstellen

Installation

bash
npm install @tanstack/query-db-collection @tanstack/query-core @tanstack/db
npm install @tanstack/query-db-collection @tanstack/query-core @tanstack/db

Grundlegende Verwendung

typescript
import { QueryClient } from '@tanstack/query-core'
import { createCollection } from '@tanstack/db'
import { queryCollectionOptions } from '@tanstack/query-db-collection'

const queryClient = new QueryClient()

const todosCollection = createCollection(
  queryCollectionOptions({
    queryKey: ['todos'],
    queryFn: async () => {
      const response = await fetch('/api/todos')
      return response.json()
    },
    queryClient,
    getKey: (item) => item.id,
  })
)
import { QueryClient } from '@tanstack/query-core'
import { createCollection } from '@tanstack/db'
import { queryCollectionOptions } from '@tanstack/query-db-collection'

const queryClient = new QueryClient()

const todosCollection = createCollection(
  queryCollectionOptions({
    queryKey: ['todos'],
    queryFn: async () => {
      const response = await fetch('/api/todos')
      return response.json()
    },
    queryClient,
    getKey: (item) => item.id,
  })
)

Konfigurationsoptionen

Die Funktion queryCollectionOptions akzeptiert die folgenden Optionen

Erforderliche Optionen

  • queryKey: Der Query-Schlüssel für TanStack Query
  • queryFn: Funktion, die Daten vom Server abruft
  • queryClient: TanStack Query Client-Instanz
  • getKey: Funktion zum Extrahieren des eindeutigen Schlüssels aus einem Element

Query Optionen

  • enabled: Ob die Abfrage automatisch ausgeführt werden soll (Standard: true)
  • refetchInterval: Refetch-Intervall in Millisekunden
  • retry: Retry-Konfiguration für fehlgeschlagene Abfragen
  • retryDelay: Verzögerung zwischen Wiederholungsversuchen
  • staleTime: Wie lange Daten als frisch gelten
  • meta: Optionale Metadaten, die an den Query-Funktionskontext übergeben werden

Sammlungsoptionen

  • id: Eindeutiger Identifikator für die Sammlung
  • schema: Schema zur Validierung von Elementen
  • sync: Benutzerdefinierte Synchronisierungskonfiguration
  • startSync: Ob die Synchronisierung sofort gestartet werden soll (Standard: true)

Persistenz-Handler

  • onInsert: Handler, der vor Einfügeoperationen aufgerufen wird
  • onUpdate: Handler, der vor Update-Operationen aufgerufen wird
  • onDelete: Handler, der vor Löschoperationen aufgerufen wird

Persistenz-Handler

Sie können Handler definieren, die bei Mutationen aufgerufen werden. Diese Handler können Änderungen an Ihrem Backend persistieren und steuern, ob die Abfrage nach der Operation erneut abgerufen werden soll

typescript
const todosCollection = createCollection(
  queryCollectionOptions({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    queryClient,
    getKey: (item) => item.id,
    
    onInsert: async ({ transaction }) => {
      const newItems = transaction.mutations.map(m => m.modified)
      await api.createTodos(newItems)
      // Returning nothing or { refetch: true } will trigger a refetch
      // Return { refetch: false } to skip automatic refetch
    },
    
    onUpdate: async ({ transaction }) => {
      const updates = transaction.mutations.map(m => ({
        id: m.key,
        changes: m.changes
      }))
      await api.updateTodos(updates)
    },
    
    onDelete: async ({ transaction }) => {
      const ids = transaction.mutations.map(m => m.key)
      await api.deleteTodos(ids)
    }
  })
)
const todosCollection = createCollection(
  queryCollectionOptions({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    queryClient,
    getKey: (item) => item.id,
    
    onInsert: async ({ transaction }) => {
      const newItems = transaction.mutations.map(m => m.modified)
      await api.createTodos(newItems)
      // Returning nothing or { refetch: true } will trigger a refetch
      // Return { refetch: false } to skip automatic refetch
    },
    
    onUpdate: async ({ transaction }) => {
      const updates = transaction.mutations.map(m => ({
        id: m.key,
        changes: m.changes
      }))
      await api.updateTodos(updates)
    },
    
    onDelete: async ({ transaction }) => {
      const ids = transaction.mutations.map(m => m.key)
      await api.deleteTodos(ids)
    }
  })
)

Steuerung des Refetch-Verhaltens

Standardmäßig wird nach Abschluss eines Persistenz-Handlers (onInsert, onUpdate oder onDelete) erfolgreich die Abfrage automatisch erneut abgerufen, um sicherzustellen, dass der lokale Zustand dem Serverzustand entspricht.

Sie können dieses Verhalten steuern, indem Sie ein Objekt mit einer refetch-Eigenschaft zurückgeben

typescript
onInsert: async ({ transaction }) => {
  await api.createTodos(transaction.mutations.map(m => m.modified))
  
  // Skip the automatic refetch
  return { refetch: false }
}
onInsert: async ({ transaction }) => {
  await api.createTodos(transaction.mutations.map(m => m.modified))
  
  // Skip the automatic refetch
  return { refetch: false }
}

Dies ist nützlich, wenn

  • Sie sicher sind, dass der Serverzustand mit dem übereinstimmt, was Sie gesendet haben
  • Sie unnötige Netzwerkanfragen vermeiden möchten
  • Sie Zustandsaktualisierungen über andere Mechanismen (wie WebSockets) verarbeiten

Dienstprogramme

Die Sammlung stellt diese Dienstprogramme über collection.utils bereit

  • refetch(): Manuelles Auslösen eines erneuten Abrufs der Abfrage

Direkte Schreibvorgänge

Direkte Schreibvorgänge sind für Szenarien gedacht, in denen der normale Abfrage-/Mutationsfluss nicht Ihren Anforderungen entspricht. Sie ermöglichen es Ihnen, direkt in den synchronisierten Datenspeicher zu schreiben, wodurch das System für optimistische Updates und der Abfrage-Refetch-Mechanismus umgangen werden.

Verständnis der Datenspeicher

Query-Sammlungen unterhalten zwei Datenspeicher

  1. Synchronisierter Datenspeicher – Der autoritative Zustand, der über queryFn mit dem Server synchronisiert wird
  2. Optimistischer Mutations-Speicher – Temporäre Änderungen, die optimistisch vor der Serverbestätigung angewendet werden

Normale Sammlungsoperationen (Einfügen, Aktualisieren, Löschen) erstellen optimistische Mutationen, die

  • Sofort auf die Benutzeroberfläche angewendet
  • Über Persistenz-Handler an den Server gesendet
  • Automatisch zurückgesetzt, wenn die Serveranfrage fehlschlägt
  • Durch Serverdaten ersetzt, wenn die Abfrage erneut abgerufen wird

Direkte Schreibvorgänge umgehen dieses System vollständig und schreiben direkt in den synchronisierten Datenspeicher, was sie ideal für die Verarbeitung von Echtzeitaktualisierungen aus alternativen Quellen macht.

Wann direkte Schreibvorgänge verwendet werden sollten

Direkte Schreibvorgänge sollten verwendet werden, wenn

  • Sie Echtzeitaktualisierungen von WebSockets oder Server-Sent Events synchronisieren müssen
  • Sie mit großen Datensätzen arbeiten, bei denen das erneute Abrufen von allem zu kostspielig ist
  • Sie inkrementelle Updates oder Updates von serverseitig berechneten Feldern erhalten
  • Sie komplexe Paginierungs- oder partielle Datenlade-Szenarien implementieren müssen

Einzelne Schreiboperationen

typescript
// Insert a new item directly to the synced data store
todosCollection.utils.writeInsert({ id: '1', text: 'Buy milk', completed: false })

// Update an existing item in the synced data store
todosCollection.utils.writeUpdate({ id: '1', completed: true })

// Delete an item from the synced data store
todosCollection.utils.writeDelete('1')

// Upsert (insert or update) in the synced data store
todosCollection.utils.writeUpsert({ id: '1', text: 'Buy milk', completed: false })
// Insert a new item directly to the synced data store
todosCollection.utils.writeInsert({ id: '1', text: 'Buy milk', completed: false })

// Update an existing item in the synced data store
todosCollection.utils.writeUpdate({ id: '1', completed: true })

// Delete an item from the synced data store
todosCollection.utils.writeDelete('1')

// Upsert (insert or update) in the synced data store
todosCollection.utils.writeUpsert({ id: '1', text: 'Buy milk', completed: false })

Diese Operationen

  • Direkt in den synchronisierten Datenspeicher schreiben
  • Keine optimistischen Mutationen erstellen
  • Keine automatischen Abfrage-Refetches auslösen
  • Den TanStack Query Cache sofort aktualisieren
  • Sofort auf der Benutzeroberfläche sichtbar sind

Batch-Operationen

Die Methode writeBatch ermöglicht es Ihnen, mehrere Operationen atomar durchzuführen. Alle Schreiboperationen, die innerhalb des Callbacks aufgerufen werden, werden gesammelt und als eine einzige Transaktion ausgeführt

typescript
todosCollection.utils.writeBatch(() => {
  todosCollection.utils.writeInsert({ id: '1', text: 'Buy milk' })
  todosCollection.utils.writeInsert({ id: '2', text: 'Walk dog' })
  todosCollection.utils.writeUpdate({ id: '3', completed: true })
  todosCollection.utils.writeDelete('4')
})
todosCollection.utils.writeBatch(() => {
  todosCollection.utils.writeInsert({ id: '1', text: 'Buy milk' })
  todosCollection.utils.writeInsert({ id: '2', text: 'Walk dog' })
  todosCollection.utils.writeUpdate({ id: '3', completed: true })
  todosCollection.utils.writeDelete('4')
})

Real-World-Beispiel: WebSocket-Integration

typescript
// Handle real-time updates from WebSocket without triggering full refetches
ws.on('todos:update', (changes) => {
  todosCollection.utils.writeBatch(() => {
    changes.forEach(change => {
      switch (change.type) {
        case 'insert':
          todosCollection.utils.writeInsert(change.data)
          break
        case 'update':
          todosCollection.utils.writeUpdate(change.data)
          break
        case 'delete':
          todosCollection.utils.writeDelete(change.id)
          break
      }
    })
  })
})
// Handle real-time updates from WebSocket without triggering full refetches
ws.on('todos:update', (changes) => {
  todosCollection.utils.writeBatch(() => {
    changes.forEach(change => {
      switch (change.type) {
        case 'insert':
          todosCollection.utils.writeInsert(change.data)
          break
        case 'update':
          todosCollection.utils.writeUpdate(change.data)
          break
        case 'delete':
          todosCollection.utils.writeDelete(change.id)
          break
      }
    })
  })
})

Beispiel: Inkrementelle Updates

typescript
// Handle server responses after mutations without full refetch
const createTodo = async (todo) => {
  // Optimistically add the todo
  const tempId = crypto.randomUUID()
  todosCollection.insert({ ...todo, id: tempId })
  
  try {
    // Send to server
    const serverTodo = await api.createTodo(todo)
    
    // Sync the server response (with server-generated ID and timestamps)
    // without triggering a full collection refetch
    todosCollection.utils.writeBatch(() => {
      todosCollection.utils.writeDelete(tempId)
      todosCollection.utils.writeInsert(serverTodo)
    })
  } catch (error) {
    // Rollback happens automatically
    throw error
  }
}
// Handle server responses after mutations without full refetch
const createTodo = async (todo) => {
  // Optimistically add the todo
  const tempId = crypto.randomUUID()
  todosCollection.insert({ ...todo, id: tempId })
  
  try {
    // Send to server
    const serverTodo = await api.createTodo(todo)
    
    // Sync the server response (with server-generated ID and timestamps)
    // without triggering a full collection refetch
    todosCollection.utils.writeBatch(() => {
      todosCollection.utils.writeDelete(tempId)
      todosCollection.utils.writeInsert(serverTodo)
    })
  } catch (error) {
    // Rollback happens automatically
    throw error
  }
}

Beispiel: Paginierung großer Datensätze

typescript
// Load additional pages without refetching existing data
const loadMoreTodos = async (page) => {
  const newTodos = await api.getTodos({ page, limit: 50 })
  
  // Add new items without affecting existing ones
  todosCollection.utils.writeBatch(() => {
    newTodos.forEach(todo => {
      todosCollection.utils.writeInsert(todo)
    })
  })
}
// Load additional pages without refetching existing data
const loadMoreTodos = async (page) => {
  const newTodos = await api.getTodos({ page, limit: 50 })
  
  // Add new items without affecting existing ones
  todosCollection.utils.writeBatch(() => {
    newTodos.forEach(todo => {
      todosCollection.utils.writeInsert(todo)
    })
  })
}

Wichtige Verhaltensweisen

Vollständige Status-Synchronisierung

Die Query-Sammlung behandelt das Ergebnis der queryFn als den **vollständigen Zustand** der Sammlung. Das bedeutet

  • In der Sammlung vorhandene Elemente, die nicht im Abfrageergebnis enthalten sind, werden gelöscht
  • Elemente im Abfrageergebnis, die nicht in der Sammlung vorhanden sind, werden eingefügt
  • Elemente, die in beiden vorhanden sind, werden aktualisiert, wenn sie sich unterscheiden

Verhalten bei leeren Arrays

Wenn queryFn ein leeres Array zurückgibt, werden **alle Elemente in der Sammlung gelöscht**. Dies liegt daran, dass die Sammlung ein leeres Array als "der Server hat keine Elemente" interpretiert.

typescript
// This will delete all items in the collection
queryFn: async () => []
// This will delete all items in the collection
queryFn: async () => []

Verarbeitung von teilweisen/inkrementellen Abrufen

Da die Query-Sammlung erwartet, dass queryFn den vollständigen Zustand zurückgibt, können Sie partielle Abrufe verarbeiten, indem Sie neue Daten mit vorhandenen Daten zusammenführen

typescript
const todosCollection = createCollection(
  queryCollectionOptions({
    queryKey: ['todos'],
    queryFn: async ({ queryKey }) => {
      // Get existing data from cache
      const existingData = queryClient.getQueryData(queryKey) || []
      
      // Fetch only new/updated items (e.g., changes since last sync)
      const lastSyncTime = localStorage.getItem('todos-last-sync')
      const newData = await fetch(`/api/todos?since=${lastSyncTime}`).then(r => r.json())
      
      // Merge new data with existing data
      const existingMap = new Map(existingData.map(item => [item.id, item]))
      
      // Apply updates and additions
      newData.forEach(item => {
        existingMap.set(item.id, item)
      })
      
      // Handle deletions if your API provides them
      if (newData.deletions) {
        newData.deletions.forEach(id => existingMap.delete(id))
      }
      
      // Update sync time
      localStorage.setItem('todos-last-sync', new Date().toISOString())
      
      // Return the complete merged state
      return Array.from(existingMap.values())
    },
    queryClient,
    getKey: (item) => item.id,
  })
)
const todosCollection = createCollection(
  queryCollectionOptions({
    queryKey: ['todos'],
    queryFn: async ({ queryKey }) => {
      // Get existing data from cache
      const existingData = queryClient.getQueryData(queryKey) || []
      
      // Fetch only new/updated items (e.g., changes since last sync)
      const lastSyncTime = localStorage.getItem('todos-last-sync')
      const newData = await fetch(`/api/todos?since=${lastSyncTime}`).then(r => r.json())
      
      // Merge new data with existing data
      const existingMap = new Map(existingData.map(item => [item.id, item]))
      
      // Apply updates and additions
      newData.forEach(item => {
        existingMap.set(item.id, item)
      })
      
      // Handle deletions if your API provides them
      if (newData.deletions) {
        newData.deletions.forEach(id => existingMap.delete(id))
      }
      
      // Update sync time
      localStorage.setItem('todos-last-sync', new Date().toISOString())
      
      // Return the complete merged state
      return Array.from(existingMap.values())
    },
    queryClient,
    getKey: (item) => item.id,
  })
)

Dieses Muster ermöglicht es Ihnen

  • Nur inkrementelle Änderungen von Ihrer API abzurufen
  • Diese Änderungen mit vorhandenen Daten zusammenzuführen
  • Den vollständigen Zustand zurückzugeben, den die Sammlung erwartet
  • Den Leistungsoverhead des wiederholten Abrufens aller Daten zu vermeiden

Direkte Schreibvorgänge und Query-Synchronisierung

Direkte Schreibvorgänge aktualisieren die Sammlung sofort und aktualisieren auch den TanStack Query Cache. Sie verhindern jedoch nicht das normale Abfrage-Synchronisierungsverhalten. Wenn Ihre queryFn Daten zurückgibt, die mit Ihren direkten Schreibvorgängen in Konflikt stehen, haben die Abfragedaten Vorrang.

Um dies ordnungsgemäß zu handhaben

  1. Verwenden Sie { refetch: false } in Ihren Persistenz-Handlern, wenn Sie direkte Schreibvorgänge verwenden
  2. Legen Sie geeignete staleTime fest, um unnötige erneute Abrufe zu vermeiden
  3. Gestalten Sie Ihre queryFn so, dass sie inkrementelle Updates berücksichtigt (z. B. nur neue Daten abrufen)

Vollständige API-Referenz für direkte Schreibvorgänge

Alle direkten Schreibmethoden sind auf collection.utils verfügbar

  • writeInsert(data): Ein oder mehrere Elemente direkt einfügen
  • writeUpdate(data): Ein oder mehrere Elemente direkt aktualisieren
  • writeDelete(keys): Ein oder mehrere Elemente direkt löschen
  • writeUpsert(data): Ein oder mehrere Elemente direkt einfügen oder aktualisieren
  • writeBatch(callback): Mehrere Operationen atomar durchführen
  • refetch(): Manuelles Auslösen eines erneuten Abrufs der Abfrage
Unsere Partner
Code Rabbit
Electric
Prisma
Bytes abonnieren

Ihre wöchentliche Dosis JavaScript-Nachrichten. Jeden Montag kostenlos an über 100.000 Entwickler geliefert.

Bytes

Kein Spam. Jederzeit kündbar.

Bytes abonnieren

Ihre wöchentliche Dosis JavaScript-Nachrichten. Jeden Montag kostenlos an über 100.000 Entwickler geliefert.

Bytes

Kein Spam. Jederzeit kündbar.