LocalStorage collections store small amounts of local-only state that persists across browser sessions and syncs between Collections in one tab and across browser tabs.
The localStorageCollectionOptions allows you to create collections that:
LocalStorage collections are included in the core TanStack DB package:
npm install @tanstack/react-dbimport { createCollection } from '@tanstack/react-db'
import { localStorageCollectionOptions } from '@tanstack/react-db'
const userPreferencesCollection = createCollection(
localStorageCollectionOptions({
id: 'user-preferences',
storageKey: 'app-user-prefs',
getKey: (item) => item.id,
})
)Important: LocalStorage collections work differently than server-synced collections. With LocalStorage collections, you directly mutate state by calling methods like collection.insert(), collection.update(), and collection.delete() — that's all you need to do. The changes are immediately applied to your local data and automatically persisted to localStorage.
This is different from collections that sync with a server (like Query Collection), where mutation handlers send data to a backend. With LocalStorage collections, everything stays local:
// Just call the methods directly - automatically persisted to localStorage
userPreferencesCollection.insert({ id: 'theme', mode: 'dark' })
userPreferencesCollection.update('theme', (draft) => { draft.mode = 'light' })
userPreferencesCollection.delete('theme')The localStorageCollectionOptions function accepts the following options:
LocalStorage collections sync across browser tabs through storage events:
const settingsCollection = createCollection(
localStorageCollectionOptions({
id: 'settings',
storageKey: 'app-settings',
getKey: (item) => item.id,
})
)
// Changes in one tab are automatically reflected in all other tabs
// This works automatically via storage eventsTwo active Collections created with fresh options also sync in the same tab when they use the same storage object and storageKey. A successful automatic write, manual acceptance, or clearStorage() updates both public snapshots without waiting for a browser event. A Collection's pending optimistic rows remain visible until their own mutations settle. The same-tab mechanism does not connect distinct custom Storage wrapper objects, even if those wrappers access the same underlying bytes.
The default JSON parser keeps a value authored by the writer in its original JavaScript form until that Collection restores or another write replaces that row. A disjoint peer write does not change the authored value. For example, a writer can still hold a Date, while a peer and a freshly restored Collection see its JSON string. With a custom parser, an authored row is read back after a successful write so the writer also sees any value that parser normalized.
Automatic mutations in one Collection persist in mutation order, even if their optional handlers finish in another order. A handler that rejects does not write its mutation. Other tabs see accepted changes when their storage events arrive. Without a handler or earlier pending write, a direct mutation writes to localStorage before the mutation method returns. A later handler failure rejects its transaction promptly, even if an earlier handler is still pending.
A handler can start another mutation on the same Collection, then return without awaiting that mutation's isPersisted promise. Do not await that promise or a later acceptMutations() call inside the earlier handler. Those writes wait for the earlier handler to return, so awaiting them would leave both pending.
Each write stores a complete storage snapshot under one storage key. A write preserves disjoint peer rows already present in storage, even when that peer's event has not arrived yet. Truly simultaneous writes from separate tabs are not an atomic transaction; localStorage has no compare-and-swap operation.
Startup requires a valid stored snapshot. Malformed JSON or a row missing its version information puts the Collection in an error state and leaves the stored bytes intact. Each stored row needs an object value, a string version token, and a storage key that decodes to the same identity as getKey for its data. New writes encode string and number keys with a type prefix; restore also accepts legacy unprefixed string keys. Two entries that decode to one identity are malformed. Repair or remove the invalid value, then restart the Collection. A Collection restoring rows can receive a same-tab peer write from a subscriber during that restore; the peer's accepted row remains visible when the Collection becomes ready. utils.clearStorage() removes the stored snapshot and immediately publishes removal of accepted rows to active same-tab Collections. A pending optimistic mutation can remain visible and later persist. Edit the Collection through its mutation methods; direct same-tab edits to its storage key do not produce browser storage events. Use an IndexedDB Collection when several tabs need stronger write coordination.
If storage cannot be read, the mutation rejects and existing stored rows remain. A failed storage-event read leaves current Collection rows unchanged until a later event can read storage successfully.
You can use sessionStorage instead of localStorage for session-only persistence:
const sessionCollection = createCollection(
localStorageCollectionOptions({
id: 'session-data',
storageKey: 'session-key',
storage: sessionStorage, // Use sessionStorage instead
getKey: (item) => item.id,
})
)Provide any storage implementation that matches the localStorage API:
// Example: Custom storage wrapper with encryption
const encryptedStorage = {
getItem(key: string) {
const encrypted = localStorage.getItem(key)
return encrypted ? decrypt(encrypted) : null
},
setItem(key: string, value: string) {
localStorage.setItem(key, encrypt(value))
},
removeItem(key: string) {
localStorage.removeItem(key)
},
}
const secureCollection = createCollection(
localStorageCollectionOptions({
id: 'secure-data',
storageKey: 'encrypted-key',
storage: encryptedStorage,
getKey: (item) => item.id,
})
)The storageEventApi option (defaults to window) allows the collection to subscribe to storage events for cross-tab synchronization. A custom storage implementation can provide this API to enable custom cross-tab, cross-window, or cross-process sync:
// Example: Custom storage event API for cross-process sync
const customStorageEventApi = {
addEventListener(event: string, handler: (e: StorageEvent) => void) {
// Custom event subscription logic
// Could be IPC, WebSocket, or any other mechanism
myCustomEventBus.on('storage-change', handler)
},
removeEventListener(event: string, handler: (e: StorageEvent) => void) {
myCustomEventBus.off('storage-change', handler)
},
}
const syncedCollection = createCollection(
localStorageCollectionOptions({
id: 'synced-data',
storageKey: 'data-key',
storage: customStorage,
storageEventApi: customStorageEventApi, // Custom event API
getKey: (item) => item.id,
})
)This enables synchronization across different contexts beyond just browser tabs, such as:
Mutation handlers are completely optional. Data will persist to localStorage whether or not you provide handlers:
const preferencesCollection = createCollection(
localStorageCollectionOptions({
id: 'preferences',
storageKey: 'user-prefs',
getKey: (item) => item.id,
// Optional: Add custom logic when preferences are updated
onUpdate: async ({ transaction }) => {
const { modified } = transaction.mutations[0]
console.log('Preference updated:', modified)
// Maybe send analytics or trigger other side effects
},
})
)When using LocalStorage collections with manual transactions (created via createTransaction), call utils.acceptMutations() in mutationFn. Manual acceptance joins the same write order, and the transaction's persistence receipt waits for the storage write even if the returned Promise is not awaited. Await it when later work in mutationFn depends on the storage write:
import { createTransaction } from '@tanstack/react-db'
const localData = createCollection(
localStorageCollectionOptions({
id: 'form-draft',
storageKey: 'draft-data',
getKey: (item) => item.id,
})
)
const serverCollection = createCollection(
queryCollectionOptions({
queryKey: ['items'],
queryFn: async () => api.items.getAll(),
getKey: (item) => item.id,
onInsert: async ({ transaction }) => {
await api.items.create(transaction.mutations[0].modified)
},
})
)
const tx = createTransaction({
mutationFn: async ({ transaction }) => {
// Handle server collection mutations explicitly in mutationFn
await Promise.all(
transaction.mutations
.filter((m) => m.collection === serverCollection)
.map((m) => api.items.create(m.modified))
)
// After server mutations succeed, persist local collection mutations
await localData.utils.acceptMutations(transaction)
},
})
// Apply mutations to both collections in one transaction
tx.mutate(() => {
localData.insert({ id: 'draft-1', data: '...' })
serverCollection.insert({ id: '1', name: 'Item' })
})
await tx.commit()Create fresh localStorageCollectionOptions() for each direct createCollection() call. One options object contains state owned by one Collection and throws if used to create a second. The same DbClient collection descriptor can still materialize fresh options for separate clients.
import { createCollection, eq } from '@tanstack/react-db'
import { localStorageCollectionOptions } from '@tanstack/react-db'
import { useLiveQuery } from '@tanstack/react-db'
import { z } from 'zod'
// Define schema
const userPrefsSchema = z.object({
id: z.string(),
theme: z.enum(['light', 'dark', 'auto']),
language: z.string(),
notifications: z.boolean(),
})
type UserPrefs = z.infer<typeof userPrefsSchema>
// Create collection
export const userPreferencesCollection = createCollection(
localStorageCollectionOptions({
id: 'user-preferences',
storageKey: 'app-user-prefs',
getKey: (item) => item.id,
schema: userPrefsSchema,
})
)
// Use in component
function SettingsPanel() {
const { data: prefs } = useLiveQuery({
query: (q) =>
q
.from({ pref: userPreferencesCollection })
.where(({ pref }) => eq(pref.id, 'current-user')),
})
const currentPrefs = prefs[0]
const updateTheme = (theme: 'light' | 'dark' | 'auto') => {
if (currentPrefs) {
userPreferencesCollection.update(currentPrefs.id, (draft) => {
draft.theme = theme
})
} else {
userPreferencesCollection.insert({
id: 'current-user',
theme,
language: 'en',
notifications: true,
})
}
}
return (
<div>
<h2>Theme: {currentPrefs?.theme}</h2>
<button onClick={() => updateTheme('dark')}>Dark Mode</button>
<button onClick={() => updateTheme('light')}>Light Mode</button>
</div>
)
}LocalStorage collections are perfect for: