Skip to main content
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('[email protected]')

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

Build docs developers (and LLMs) love