@@ -9,6 +9,7 @@ Query collections provide seamless integration between TanStack DB and TanStack
99## Overview
1010
1111The ` @tanstack/query-db-collection ` package allows you to create collections that:
12+
1213- Automatically sync with remote data via TanStack Query
1314- Support optimistic updates with automatic rollback on errors
1415- Handle persistence through customizable mutation handlers
@@ -23,17 +24,17 @@ npm install @tanstack/query-db-collection @tanstack/query-core @tanstack/db
2324## Basic Usage
2425
2526``` typescript
26- import { QueryClient } from ' @tanstack/query-core'
27- import { createCollection } from ' @tanstack/db'
28- import { queryCollectionOptions } from ' @tanstack/query-db-collection'
27+ import { QueryClient } from " @tanstack/query-core"
28+ import { createCollection } from " @tanstack/db"
29+ import { queryCollectionOptions } from " @tanstack/query-db-collection"
2930
3031const queryClient = new QueryClient ()
3132
3233const todosCollection = createCollection (
3334 queryCollectionOptions ({
34- queryKey: [' todos' ],
35+ queryKey: [" todos" ],
3536 queryFn : async () => {
36- const response = await fetch (' /api/todos' )
37+ const response = await fetch (" /api/todos" )
3738 return response .json ()
3839 },
3940 queryClient ,
@@ -55,7 +56,7 @@ The `queryCollectionOptions` function accepts the following options:
5556
5657### Query Options
5758
58- - ` select ` : Function that lets extract array items when they’ re wrapped with metadata
59+ - ` select ` : Function that lets extract array items when they' re wrapped with metadata
5960- ` enabled ` : Whether the query should automatically run (default: ` true ` )
6061- ` refetchInterval ` : Refetch interval in milliseconds
6162- ` retry ` : Retry configuration for failed queries
@@ -83,30 +84,30 @@ You can define handlers that are called when mutations occur. These handlers can
8384``` typescript
8485const todosCollection = createCollection (
8586 queryCollectionOptions ({
86- queryKey: [' todos' ],
87+ queryKey: [" todos" ],
8788 queryFn: fetchTodos ,
8889 queryClient ,
8990 getKey : (item ) => item .id ,
90-
91+
9192 onInsert : async ({ transaction }) => {
92- const newItems = transaction .mutations .map (m => m .modified )
93+ const newItems = transaction .mutations .map (( m ) => m .modified )
9394 await api .createTodos (newItems )
9495 // Returning nothing or { refetch: true } will trigger a refetch
9596 // Return { refetch: false } to skip automatic refetch
9697 },
97-
98+
9899 onUpdate : async ({ transaction }) => {
99- const updates = transaction .mutations .map (m => ({
100+ const updates = transaction .mutations .map (( m ) => ({
100101 id: m .key ,
101- changes: m .changes
102+ changes: m .changes ,
102103 }))
103104 await api .updateTodos (updates )
104105 },
105-
106+
106107 onDelete : async ({ transaction }) => {
107- const ids = transaction .mutations .map (m => m .key )
108+ const ids = transaction .mutations .map (( m ) => m .key )
108109 await api .deleteTodos (ids )
109- }
110+ },
110111 })
111112)
112113```
@@ -119,14 +120,15 @@ You can control this behavior by returning an object with a `refetch` property:
119120
120121``` typescript
121122onInsert : async ({ transaction }) => {
122- await api .createTodos (transaction .mutations .map (m => m .modified ))
123-
123+ await api .createTodos (transaction .mutations .map (( m ) => m .modified ))
124+
124125 // Skip the automatic refetch
125126 return { refetch: false }
126127}
127128```
128129
129130This is useful when:
131+
130132- You're confident the server state matches what you sent
131133- You want to avoid unnecessary network requests
132134- You're handling state updates through other mechanisms (like WebSockets)
@@ -135,7 +137,10 @@ This is useful when:
135137
136138The collection provides these utility methods via ` collection.utils ` :
137139
138- - ` refetch() ` : Manually trigger a refetch of the query
140+ - ` refetch(opts?) ` : Manually trigger a refetch of the query
141+ - ` opts.throwOnError ` : Whether to throw an error if the refetch fails (default: ` false ` )
142+ - Bypasses ` enabled: false ` to support imperative/manual refetching patterns (similar to hook ` refetch() ` behavior)
143+ - Returns ` QueryObserverResult ` for inspecting the result
139144
140145## Direct Writes
141146
@@ -144,10 +149,12 @@ Direct writes are intended for scenarios where the normal query/mutation flow do
144149### Understanding the Data Stores
145150
146151Query Collections maintain two data stores:
152+
1471531 . ** Synced Data Store** - The authoritative state synchronized with the server via ` queryFn `
1481542 . ** Optimistic Mutations Store** - Temporary changes that are applied optimistically before server confirmation
149155
150156Normal collection operations (insert, update, delete) create optimistic mutations that are:
157+
151158- Applied immediately to the UI
152159- Sent to the server via persistence handlers
153160- Rolled back automatically if the server request fails
@@ -158,6 +165,7 @@ Direct writes bypass this system entirely and write directly to the synced data
158165### When to Use Direct Writes
159166
160167Direct writes should be used when:
168+
161169- You need to sync real-time updates from WebSockets or server-sent events
162170- You're dealing with large datasets where refetching everything is too expensive
163171- You receive incremental updates or server-computed field updates
@@ -167,19 +175,28 @@ Direct writes should be used when:
167175
168176``` typescript
169177// Insert a new item directly to the synced data store
170- todosCollection .utils .writeInsert ({ id: ' 1' , text: ' Buy milk' , completed: false })
178+ todosCollection .utils .writeInsert ({
179+ id: " 1" ,
180+ text: " Buy milk" ,
181+ completed: false ,
182+ })
171183
172184// Update an existing item in the synced data store
173- todosCollection .utils .writeUpdate ({ id: ' 1 ' , completed: true })
185+ todosCollection .utils .writeUpdate ({ id: " 1 " , completed: true })
174186
175187// Delete an item from the synced data store
176- todosCollection .utils .writeDelete (' 1 ' )
188+ todosCollection .utils .writeDelete (" 1 " )
177189
178190// Upsert (insert or update) in the synced data store
179- todosCollection .utils .writeUpsert ({ id: ' 1' , text: ' Buy milk' , completed: false })
191+ todosCollection .utils .writeUpsert ({
192+ id: " 1" ,
193+ text: " Buy milk" ,
194+ completed: false ,
195+ })
180196```
181197
182198These operations:
199+
183200- Write directly to the synced data store
184201- Do NOT create optimistic mutations
185202- Do NOT trigger automatic query refetches
@@ -192,28 +209,28 @@ The `writeBatch` method allows you to perform multiple operations atomically. An
192209
193210``` typescript
194211todosCollection .utils .writeBatch (() => {
195- todosCollection .utils .writeInsert ({ id: ' 1 ' , text: ' Buy milk' })
196- todosCollection .utils .writeInsert ({ id: ' 2 ' , text: ' Walk dog' })
197- todosCollection .utils .writeUpdate ({ id: ' 3 ' , completed: true })
198- todosCollection .utils .writeDelete (' 4 ' )
212+ todosCollection .utils .writeInsert ({ id: " 1 " , text: " Buy milk" })
213+ todosCollection .utils .writeInsert ({ id: " 2 " , text: " Walk dog" })
214+ todosCollection .utils .writeUpdate ({ id: " 3 " , completed: true })
215+ todosCollection .utils .writeDelete (" 4 " )
199216})
200217```
201218
202219### Real-World Example: WebSocket Integration
203220
204221``` typescript
205222// Handle real-time updates from WebSocket without triggering full refetches
206- ws .on (' todos:update' , (changes ) => {
223+ ws .on (" todos:update" , (changes ) => {
207224 todosCollection .utils .writeBatch (() => {
208- changes .forEach (change => {
225+ changes .forEach (( change ) => {
209226 switch (change .type ) {
210- case ' insert' :
227+ case " insert" :
211228 todosCollection .utils .writeInsert (change .data )
212229 break
213- case ' update' :
230+ case " update" :
214231 todosCollection .utils .writeUpdate (change .data )
215232 break
216- case ' delete' :
233+ case " delete" :
217234 todosCollection .utils .writeDelete (change .id )
218235 break
219236 }
@@ -229,21 +246,21 @@ When the server returns computed fields (like server-generated IDs or timestamps
229246``` typescript
230247const todosCollection = createCollection (
231248 queryCollectionOptions ({
232- queryKey: [' todos' ],
249+ queryKey: [" todos" ],
233250 queryFn: fetchTodos ,
234251 queryClient ,
235252 getKey : (item ) => item .id ,
236253
237254 onInsert : async ({ transaction }) => {
238- const newItems = transaction .mutations .map (m => m .modified )
255+ const newItems = transaction .mutations .map (( m ) => m .modified )
239256
240257 // Send to server and get back items with server-computed fields
241258 const serverItems = await api .createTodos (newItems )
242259
243260 // Sync server-computed fields (like server-generated IDs, timestamps, etc.)
244261 // to the collection's synced data store
245262 todosCollection .utils .writeBatch (() => {
246- serverItems .forEach (serverItem => {
263+ serverItems .forEach (( serverItem ) => {
247264 todosCollection .utils .writeInsert (serverItem )
248265 })
249266 })
@@ -254,26 +271,26 @@ const todosCollection = createCollection(
254271 },
255272
256273 onUpdate : async ({ transaction }) => {
257- const updates = transaction .mutations .map (m => ({
274+ const updates = transaction .mutations .map (( m ) => ({
258275 id: m .key ,
259- changes: m .changes
276+ changes: m .changes ,
260277 }))
261278 const serverItems = await api .updateTodos (updates )
262279
263280 // Sync server-computed fields from the update response
264281 todosCollection .utils .writeBatch (() => {
265- serverItems .forEach (serverItem => {
282+ serverItems .forEach (( serverItem ) => {
266283 todosCollection .utils .writeUpdate (serverItem )
267284 })
268285 })
269286
270287 return { refetch: false }
271- }
288+ },
272289 })
273290)
274291
275292// Usage is just like a regular collection
276- todosCollection .insert ({ text: ' Buy milk' , completed: false })
293+ todosCollection .insert ({ text: " Buy milk" , completed: false })
277294```
278295
279296### Example: Large Dataset Pagination
@@ -282,10 +299,10 @@ todosCollection.insert({ text: 'Buy milk', completed: false })
282299// Load additional pages without refetching existing data
283300const loadMoreTodos = async (page ) => {
284301 const newTodos = await api .getTodos ({ page , limit: 50 })
285-
302+
286303 // Add new items without affecting existing ones
287304 todosCollection .utils .writeBatch (() => {
288- newTodos .forEach (todo => {
305+ newTodos .forEach (( todo ) => {
289306 todosCollection .utils .writeInsert (todo )
290307 })
291308 })
@@ -318,31 +335,33 @@ Since the query collection expects `queryFn` to return the complete state, you c
318335``` typescript
319336const todosCollection = createCollection (
320337 queryCollectionOptions ({
321- queryKey: [' todos' ],
338+ queryKey: [" todos" ],
322339 queryFn : async ({ queryKey }) => {
323340 // Get existing data from cache
324341 const existingData = queryClient .getQueryData (queryKey ) || []
325-
342+
326343 // Fetch only new/updated items (e.g., changes since last sync)
327- const lastSyncTime = localStorage .getItem (' todos-last-sync' )
328- const newData = await fetch (` /api/todos?since=${lastSyncTime } ` ).then (r => r .json ())
329-
344+ const lastSyncTime = localStorage .getItem (" todos-last-sync" )
345+ const newData = await fetch (` /api/todos?since=${lastSyncTime } ` ).then (
346+ (r ) => r .json ()
347+ )
348+
330349 // Merge new data with existing data
331- const existingMap = new Map (existingData .map (item => [item .id , item ]))
332-
350+ const existingMap = new Map (existingData .map (( item ) => [item .id , item ]))
351+
333352 // Apply updates and additions
334- newData .forEach (item => {
353+ newData .forEach (( item ) => {
335354 existingMap .set (item .id , item )
336355 })
337-
356+
338357 // Handle deletions if your API provides them
339358 if (newData .deletions ) {
340- newData .deletions .forEach (id => existingMap .delete (id ))
359+ newData .deletions .forEach (( id ) => existingMap .delete (id ))
341360 }
342-
361+
343362 // Update sync time
344- localStorage .setItem (' todos-last-sync' , new Date ().toISOString ())
345-
363+ localStorage .setItem (" todos-last-sync" , new Date ().toISOString ())
364+
346365 // Return the complete merged state
347366 return Array .from (existingMap .values ())
348367 },
@@ -353,6 +372,7 @@ const todosCollection = createCollection(
353372```
354373
355374This pattern allows you to:
375+
356376- Fetch only incremental changes from your API
357377- Merge those changes with existing data
358378- Return the complete state that the collection expects
@@ -363,6 +383,7 @@ This pattern allows you to:
363383Direct writes update the collection immediately and also update the TanStack Query cache. However, they do not prevent the normal query sync behavior. If your ` queryFn ` returns data that conflicts with your direct writes, the query data will take precedence.
364384
365385To handle this properly:
386+
3663871 . Use ` { refetch: false } ` in your persistence handlers when using direct writes
3673882 . Set appropriate ` staleTime ` to prevent unnecessary refetches
3683893 . Design your ` queryFn ` to be aware of incremental updates (e.g., only fetch new data)
@@ -376,4 +397,4 @@ All direct write methods are available on `collection.utils`:
376397- ` writeDelete(keys) ` : Delete one or more items directly
377398- ` writeUpsert(data) ` : Insert or update one or more items directly
378399- ` writeBatch(callback) ` : Perform multiple operations atomically
379- - ` refetch() ` : Manually trigger a refetch of the query
400+ - ` refetch(opts? ) ` : Manually trigger a refetch of the query
0 commit comments