Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/LegendApp/legend-state/llms.txt

Use this file to discover all available pages before exploring further.

Legend-State provides a comprehensive sync and persistence system designed for local-first applications. It enables you to build apps that work offline, sync with remote backends, and provide a seamless user experience with optimistic updates and automatic conflict resolution.

Why Local-First?

Legend-State’s sync system is built on local-first principles:
  • Optimistic Updates: Changes are applied locally first, making your app feel instant
  • Offline Support: Apps continue working without a network connection
  • Automatic Retry: Failed syncs are automatically retried, even after app restart
  • Minimal Diffs: Only changed data is synced, reducing bandwidth usage
  • Conflict Resolution: Built-in strategies for handling sync conflicts
Legend-State powers the sync systems in production apps like Legend and Bravely, making it battle-tested for real-world applications.

Core Concepts

Local Persistence

Local persistence saves your observable state to device storage automatically:
import { observable } from '@legendapp/state'
import { synced } from '@legendapp/state/sync'
import { ObservablePersistLocalStorage } from '@legendapp/state/persist-plugins/local-storage'

const settings$ = observable(synced({
  persist: {
    name: 'settings',
    plugin: ObservablePersistLocalStorage
  },
  initial: { theme: 'dark', fontSize: 14 }
}))

// Changes are automatically saved to localStorage
settings$.theme.set('light')

Remote Sync

Remote sync connects your local state to a backend service:
import { syncedKeel } from '@legendapp/state/sync-plugins/keel'

const users$ = observable(syncedKeel({
  list: queries.getUsers,
  create: mutations.createUser,
  update: mutations.updateUser,
  delete: mutations.deleteUser,
  persist: { name: 'users', retrySync: true },
  changesSince: 'last-sync'
}))

// Changes sync to remote automatically
users$['user-123'].name.set('Alice')

System Architecture

1

Local Change

User modifies data, change is applied immediately to the observable
2

Persist Locally

Change is saved to local storage (IndexedDB, localStorage, MMKV, etc.)
3

Mark as Pending

If remote sync is configured, change is marked as pending in metadata
4

Sync to Remote

Change is sent to the remote backend after a debounce period
5

Handle Response

Remote response is merged back, pending status is cleared
6

Retry on Failure

If sync fails, automatic retry with exponential backoff

Sync Flow

Available Persistence Plugins

Legend-State includes plugins for various storage backends:
PluginPlatformUse Case
ObservablePersistLocalStorageWebBrowser localStorage
ObservablePersistIndexedDBWebLarge datasets, structured data
ObservablePersistSessionStorageWebSession-only data
ObservablePersistMMKVReact NativeFast, encrypted storage
ObservablePersistAsyncStorageReact NativeAsync key-value store
ObservablePersistExpoSQLiteExpoSQLite database

Available Sync Plugins

Sync plugins connect to popular backend services:
PluginBackendFeatures
syncedKeelKeelFull CRUD, auth, realtime
syncedSupabaseSupabasePostgreSQL, realtime subscriptions
syncedFirebaseFirebaseRealtime Database
syncedCrudCustomGeneric CRUD operations
syncedFetchAny REST APISimple fetch-based sync
syncedTanstackTanStack QueryReact Query integration

Key Features

Automatic Persistence

Changes are automatically saved to local storage without any manual intervention:
const state$ = observable(synced({
  persist: { name: 'myState', plugin: ObservablePersistLocalStorage },
  initial: { count: 0 }
}))

// Automatically saved to localStorage
state$.count.set(42)

Pending Changes Tracking

Legend-State tracks which changes haven’t synced yet:
const syncState$ = syncState(state$)

// Check if there are pending changes
syncState$.numPendingSets.get() // number of pending operations
syncState$.isSetting.get() // true if currently syncing

Retry with Backoff

Failed syncs are automatically retried with configurable strategies:
const state$ = observable(synced({
  persist: { name: 'data', retrySync: true },
  retry: {
    infinite: true, // Never give up
    delay: 1000, // Start with 1 second
    backoff: 'exponential', // Double delay each retry
    maxDelay: 30000 // Cap at 30 seconds
  }
}))

Debounced Sync

Batch multiple rapid changes into a single sync operation:
const state$ = observable(synced({
  debounceSet: 500, // Wait 500ms after last change
  set: async ({ changes }) => {
    // This is called once for batched changes
  }
}))

// These three changes result in one sync call
state$.name.set('Alice')
state$.age.set(30)
state$.email.set('alice@example.com')

Transform Data

Transform data between local and remote formats:
const state$ = observable(synced({
  transform: {
    load: (value) => {
      // Transform from remote format to local
      return { ...value, loadedAt: Date.now() }
    },
    save: (value) => {
      // Transform from local format to remote
      const { loadedAt, ...rest } = value
      return rest
    }
  }
}))

Sync State

Every synced observable has an associated sync state that tracks its status:
import { syncState } from '@legendapp/state'

const data$ = observable(synced({ /* ... */ }))
const state$ = syncState(data$)

// Monitor sync status
state$.isLoaded.get() // Has initial load completed?
state$.isSetting.get() // Currently syncing changes?
state$.isPersistLoaded.get() // Has local cache loaded?
state$.numPendingSets.get() // How many pending sync operations?
state$.lastSync.get() // Timestamp of last successful sync
state$.error.get() // Last error, if any

Sync State Methods

// Manually trigger a sync
await state$.sync()

// Clear local cache
await state$.resetPersistence()

// Get pending changes
const pending = state$.getPendingChanges()

Configuration

Global Configuration

Set defaults for all synced observables:
import { configureObservableSync } from '@legendapp/state/sync'
import { ObservablePersistLocalStorage } from '@legendapp/state/persist-plugins/local-storage'

configureObservableSync({
  persist: {
    plugin: ObservablePersistLocalStorage
  },
  retry: {
    infinite: true,
    delay: 1000,
    backoff: 'exponential'
  },
  debounceSet: 500
})

Per-Observable Configuration

Override global defaults for specific observables:
const data$ = observable(synced({
  persist: { name: 'special-data' },
  retry: { times: 3 }, // Override global retry
  debounceSet: 1000 // Override global debounce
}))

Best Practices

Always use unique persist names: Each synced observable should have a unique persist.name to avoid data conflicts.
Enable retrySync for critical data: Set persist.retrySync: true to ensure changes eventually sync even after app restarts.
const userData$ = observable(synced({
  // Unique name for this data
  persist: {
    name: 'userData',
    plugin: ObservablePersistLocalStorage,
    retrySync: true // Persist pending changes
  },
  
  // Initial data while loading
  initial: { name: '', email: '' },
  
  // Retry failed syncs indefinitely
  retry: {
    infinite: true,
    backoff: 'exponential'
  },
  
  // Batch rapid changes
  debounceSet: 500,
  
  // Only sync changes since last sync
  changesSince: 'last-sync'
}))

Next Steps

synced() Function

Learn how to create synced observables

Local Persistence

Configure local storage plugins

Remote Sync

Connect to backend services

Conflict Resolution

Handle sync conflicts