TanStack
Collections

LocalStorage Collection

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.

Overview

The localStorageCollectionOptions allows you to create collections that:

  • Persist data to localStorage (or sessionStorage)
  • Automatically sync across browser tabs using storage events
  • Synchronize active same-tab Collections sharing one storage object and key
  • Support optimistic updates with automatic rollback on errors
  • Store all data under a single localStorage key
  • Work with any storage API that matches the localStorage interface

Installation

LocalStorage collections are included in the core TanStack DB package:

shell
npm install @tanstack/react-db

Basic Usage

ts
import { 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,
  })
)

Direct Local Mutations

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:

ts
// 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')

Configuration Options

The localStorageCollectionOptions function accepts the following options:

Required Options

  • id: Unique identifier for the collection
  • storageKey: The localStorage key where all collection data is stored
  • getKey: Function to extract the unique key from an item

Optional Options

  • schema: Standard Schema compatible schema (e.g., Zod, Effect) for client-side validation
  • storage: Custom storage implementation (defaults to localStorage). Can be sessionStorage or any object with the localStorage API
  • storageEventApi: Event API for subscribing to storage events (defaults to window). Enables custom cross-tab, cross-window, or cross-process synchronization
  • onInsert: Optional handler function called when items are inserted
  • onUpdate: Optional handler function called when items are updated
  • onDelete: Optional handler function called when items are deleted

Synchronization

LocalStorage collections sync across browser tabs through storage events:

ts
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 events

Two 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.

Using SessionStorage

You can use sessionStorage instead of localStorage for session-only persistence:

ts
const sessionCollection = createCollection(
  localStorageCollectionOptions({
    id: 'session-data',
    storageKey: 'session-key',
    storage: sessionStorage, // Use sessionStorage instead
    getKey: (item) => item.id,
  })
)

Custom Storage Backend

Provide any storage implementation that matches the localStorage API:

ts
// 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,
  })
)

Cross-Tab Sync with Custom Storage

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:

ts
// 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:

  • Cross-process communication in Electron apps
  • WebSocket-based sync across multiple browser windows
  • Custom IPC mechanisms in desktop applications

Mutation Handlers

Mutation handlers are completely optional. Data will persist to localStorage whether or not you provide handlers:

ts
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
    },
  })
)

Manual Transactions

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:

ts
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.

Complete Example

ts
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>
  )
}

Use Cases

LocalStorage collections are perfect for:

  • User preferences and settings
  • UI state that should persist across sessions
  • Form drafts
  • Recently viewed items
  • User-specific configurations
  • Small amounts of cached data

Learn More