LocalOnly Collection

LocalOnly Collection

LocalOnly collections are designed for in-memory client data or UI state that doesn't need to persist across browser sessions or sync across tabs.

Overview

The localOnlyCollectionOptions allows you to create collections that:

  • Store data only in memory (no persistence)
  • Support optimistic updates with automatic rollback on errors
  • Provide optional initial data
  • Work perfectly for temporary UI state and session-only data
  • Automatically manage the transition from optimistic to confirmed state

Installation

LocalOnly collections are included in the core TanStack DB package:

bash
npm install @tanstack/react-db
npm install @tanstack/react-db

Basic Usage

typescript
import { createCollection } from '@tanstack/react-db'
import { localOnlyCollectionOptions } from '@tanstack/react-db'

const uiStateCollection = createCollection(
  localOnlyCollectionOptions({
    id: 'ui-state',
    getKey: (item) => item.id,
  })
)
import { createCollection } from '@tanstack/react-db'
import { localOnlyCollectionOptions } from '@tanstack/react-db'

const uiStateCollection = createCollection(
  localOnlyCollectionOptions({
    id: 'ui-state',
    getKey: (item) => item.id,
  })
)

Direct Local Mutations

Important: LocalOnly collections work differently than server-synced collections. With LocalOnly 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 in-memory data.

This is different from collections that sync with a server (like Query Collection), where mutation handlers send data to a backend. With LocalOnly collections, everything stays local:

typescript
// Just call the methods directly - no server sync involved
uiStateCollection.insert({ id: 'theme', mode: 'dark' })
uiStateCollection.update('theme', (draft) => { draft.mode = 'light' })
uiStateCollection.delete('theme')
// Just call the methods directly - no server sync involved
uiStateCollection.insert({ id: 'theme', mode: 'dark' })
uiStateCollection.update('theme', (draft) => { draft.mode = 'light' })
uiStateCollection.delete('theme')

Configuration Options

The localOnlyCollectionOptions function accepts the following options:

Required Options

  • id: Unique identifier for the collection
  • 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
  • initialData: Array of items to populate the collection with on creation
  • onInsert: Optional handler function called before confirming inserts
  • onUpdate: Optional handler function called before confirming updates
  • onDelete: Optional handler function called before confirming deletes

Initial Data

Populate the collection with initial data on creation:

typescript
const uiStateCollection = createCollection(
  localOnlyCollectionOptions({
    id: 'ui-state',
    getKey: (item) => item.id,
    initialData: [
      { id: 'sidebar', isOpen: false },
      { id: 'theme', mode: 'light' },
      { id: 'modal', visible: false },
    ],
  })
)
const uiStateCollection = createCollection(
  localOnlyCollectionOptions({
    id: 'ui-state',
    getKey: (item) => item.id,
    initialData: [
      { id: 'sidebar', isOpen: false },
      { id: 'theme', mode: 'light' },
      { id: 'modal', visible: false },
    ],
  })
)

Mutation Handlers

Mutation handlers are completely optional. When provided, they are called before the optimistic state is confirmed:

typescript
const tempDataCollection = createCollection(
  localOnlyCollectionOptions({
    id: 'temp-data',
    getKey: (item) => item.id,
    onInsert: async ({ transaction }) => {
      // Custom logic before confirming the insert
      console.log('Inserting:', transaction.mutations[0].modified)
    },
    onUpdate: async ({ transaction }) => {
      // Custom logic before confirming the update
      const { original, modified } = transaction.mutations[0]
      console.log('Updating from', original, 'to', modified)
    },
    onDelete: async ({ transaction }) => {
      // Custom logic before confirming the delete
      console.log('Deleting:', transaction.mutations[0].original)
    },
  })
)
const tempDataCollection = createCollection(
  localOnlyCollectionOptions({
    id: 'temp-data',
    getKey: (item) => item.id,
    onInsert: async ({ transaction }) => {
      // Custom logic before confirming the insert
      console.log('Inserting:', transaction.mutations[0].modified)
    },
    onUpdate: async ({ transaction }) => {
      // Custom logic before confirming the update
      const { original, modified } = transaction.mutations[0]
      console.log('Updating from', original, 'to', modified)
    },
    onDelete: async ({ transaction }) => {
      // Custom logic before confirming the delete
      console.log('Deleting:', transaction.mutations[0].original)
    },
  })
)

Manual Transactions

When using LocalOnly collections with manual transactions (created via createTransaction), you must call utils.acceptMutations() to persist the changes:

typescript
import { createTransaction } from '@tanstack/react-db'

const localData = createCollection(
  localOnlyCollectionOptions({
    id: 'form-draft',
    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, accept local collection mutations
    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()
import { createTransaction } from '@tanstack/react-db'

const localData = createCollection(
  localOnlyCollectionOptions({
    id: 'form-draft',
    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, accept local collection mutations
    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()

Complete Example: Modal State Management

typescript
import { createCollection } from '@tanstack/react-db'
import { localOnlyCollectionOptions } from '@tanstack/react-db'
import { useLiveQuery } from '@tanstack/react-db'
import { z } from 'zod'

// Define schema
const modalStateSchema = z.object({
  id: z.string(),
  isOpen: z.boolean(),
  data: z.any().optional(),
})

type ModalState = z.infer<typeof modalStateSchema>

// Create collection
export const modalStateCollection = createCollection(
  localOnlyCollectionOptions({
    id: 'modal-state',
    getKey: (item) => item.id,
    schema: modalStateSchema,
    initialData: [
      { id: 'user-profile', isOpen: false },
      { id: 'settings', isOpen: false },
      { id: 'confirm-delete', isOpen: false },
    ],
  })
)

// Use in component
function UserProfileModal() {
  const { data: modals } = useLiveQuery((q) =>
    q.from({ modal: modalStateCollection })
      .where(({ modal }) => modal.id === 'user-profile')
  )

  const modalState = modals[0]

  const openModal = (data?: any) => {
    modalStateCollection.update('user-profile', (draft) => {
      draft.isOpen = true
      draft.data = data
    })
  }

  const closeModal = () => {
    modalStateCollection.update('user-profile', (draft) => {
      draft.isOpen = false
      draft.data = undefined
    })
  }

  if (!modalState?.isOpen) return null

  return (
    <div className="modal">
      <h2>User Profile</h2>
      <pre>{JSON.stringify(modalState.data, null, 2)}</pre>
      <button onClick={closeModal}>Close</button>
    </div>
  )
}
import { createCollection } from '@tanstack/react-db'
import { localOnlyCollectionOptions } from '@tanstack/react-db'
import { useLiveQuery } from '@tanstack/react-db'
import { z } from 'zod'

// Define schema
const modalStateSchema = z.object({
  id: z.string(),
  isOpen: z.boolean(),
  data: z.any().optional(),
})

type ModalState = z.infer<typeof modalStateSchema>

// Create collection
export const modalStateCollection = createCollection(
  localOnlyCollectionOptions({
    id: 'modal-state',
    getKey: (item) => item.id,
    schema: modalStateSchema,
    initialData: [
      { id: 'user-profile', isOpen: false },
      { id: 'settings', isOpen: false },
      { id: 'confirm-delete', isOpen: false },
    ],
  })
)

// Use in component
function UserProfileModal() {
  const { data: modals } = useLiveQuery((q) =>
    q.from({ modal: modalStateCollection })
      .where(({ modal }) => modal.id === 'user-profile')
  )

  const modalState = modals[0]

  const openModal = (data?: any) => {
    modalStateCollection.update('user-profile', (draft) => {
      draft.isOpen = true
      draft.data = data
    })
  }

  const closeModal = () => {
    modalStateCollection.update('user-profile', (draft) => {
      draft.isOpen = false
      draft.data = undefined
    })
  }

  if (!modalState?.isOpen) return null

  return (
    <div className="modal">
      <h2>User Profile</h2>
      <pre>{JSON.stringify(modalState.data, null, 2)}</pre>
      <button onClick={closeModal}>Close</button>
    </div>
  )
}

Complete Example: Form Draft State

typescript
import { createCollection } from '@tanstack/react-db'
import { localOnlyCollectionOptions } from '@tanstack/react-db'
import { useLiveQuery } from '@tanstack/react-db'

type FormDraft = {
  id: string
  formData: Record<string, any>
  lastModified: Date
}

// Create collection for form drafts
export const formDraftsCollection = createCollection(
  localOnlyCollectionOptions({
    id: 'form-drafts',
    getKey: (item) => item.id,
  })
)

// Use in component
function CreatePostForm() {
  const { data: drafts } = useLiveQuery((q) =>
    q.from({ draft: formDraftsCollection })
      .where(({ draft }) => draft.id === 'new-post')
  )

  const currentDraft = drafts[0]

  const updateDraft = (field: string, value: any) => {
    if (currentDraft) {
      formDraftsCollection.update('new-post', (draft) => {
        draft.formData[field] = value
        draft.lastModified = new Date()
      })
    } else {
      formDraftsCollection.insert({
        id: 'new-post',
        formData: { [field]: value },
        lastModified: new Date(),
      })
    }
  }

  const clearDraft = () => {
    if (currentDraft) {
      formDraftsCollection.delete('new-post')
    }
  }

  const submitForm = async () => {
    if (!currentDraft) return

    await api.posts.create(currentDraft.formData)
    clearDraft()
  }

  return (
    <form onSubmit={(e) => { e.preventDefault(); submitForm() }}>
      <input
        value={currentDraft?.formData.title || ''}
        onChange={(e) => updateDraft('title', e.target.value)}
      />
      <button type="submit">Publish</button>
      <button type="button" onClick={clearDraft}>Clear Draft</button>
    </form>
  )
}
import { createCollection } from '@tanstack/react-db'
import { localOnlyCollectionOptions } from '@tanstack/react-db'
import { useLiveQuery } from '@tanstack/react-db'

type FormDraft = {
  id: string
  formData: Record<string, any>
  lastModified: Date
}

// Create collection for form drafts
export const formDraftsCollection = createCollection(
  localOnlyCollectionOptions({
    id: 'form-drafts',
    getKey: (item) => item.id,
  })
)

// Use in component
function CreatePostForm() {
  const { data: drafts } = useLiveQuery((q) =>
    q.from({ draft: formDraftsCollection })
      .where(({ draft }) => draft.id === 'new-post')
  )

  const currentDraft = drafts[0]

  const updateDraft = (field: string, value: any) => {
    if (currentDraft) {
      formDraftsCollection.update('new-post', (draft) => {
        draft.formData[field] = value
        draft.lastModified = new Date()
      })
    } else {
      formDraftsCollection.insert({
        id: 'new-post',
        formData: { [field]: value },
        lastModified: new Date(),
      })
    }
  }

  const clearDraft = () => {
    if (currentDraft) {
      formDraftsCollection.delete('new-post')
    }
  }

  const submitForm = async () => {
    if (!currentDraft) return

    await api.posts.create(currentDraft.formData)
    clearDraft()
  }

  return (
    <form onSubmit={(e) => { e.preventDefault(); submitForm() }}>
      <input
        value={currentDraft?.formData.title || ''}
        onChange={(e) => updateDraft('title', e.target.value)}
      />
      <button type="submit">Publish</button>
      <button type="button" onClick={clearDraft}>Clear Draft</button>
    </form>
  )
}

Use Cases

LocalOnly collections are perfect for:

  • Temporary UI state (modals, sidebars, tooltips)
  • Form draft data during the current session
  • Client-side computed or derived data
  • Wizard/multi-step form state
  • Temporary filters or search state
  • In-memory caches

Comparison with LocalStorageCollection

FeatureLocalOnlyLocalStorage
PersistenceNone (in-memory only)localStorage
Cross-tab syncNoYes
Survives page reloadNoYes
PerformanceFastestFast
Size limitsMemory limits~5-10MB
Best forTemporary UI stateUser preferences

Learn More