Legend-State’s persistence plugins automatically save your observable state to local storage. Changes are saved incrementally, so only what changed is written to disk, making it extremely efficient even for large datasets.
Available Plugins
Web Plugins
localStorage
Simple key-value storage for web browsers:
import { ObservablePersistLocalStorage } from '@legendapp/state/persist-plugins/local-storage'
import { synced } from '@legendapp/state/sync'
import { observable } from '@legendapp/state'
const settings$ = observable ( synced ({
persist: {
name: 'settings' ,
plugin: ObservablePersistLocalStorage
},
initial: { theme: 'dark' , fontSize: 14 }
}))
Characteristics :
Max storage: ~5-10MB
Synchronous API
String-only values (JSON stringified)
Persists across sessions
Simple key-value pairs
sessionStorage
Session-only storage that clears on tab close:
import { ObservablePersistSessionStorage } from '@legendapp/state/persist-plugins/local-storage'
const tempData$ = observable ( synced ({
persist: {
name: 'tempData' ,
plugin: ObservablePersistSessionStorage
}
}))
Characteristics :
Same as localStorage
Clears when tab/window closes
Separate storage per tab
IndexedDB
Structured database for large datasets:
import { observablePersistIndexedDB } from '@legendapp/state/persist-plugins/indexeddb'
// Configure the database
const idbPlugin = observablePersistIndexedDB ({
databaseName: 'AppDB' ,
version: 1 ,
tableNames: [ 'users' , 'posts' , 'comments' ]
})
const users$ = observable ( synced ({
persist: {
name: 'users' ,
plugin: idbPlugin
},
initial: {}
}))
Configuration Options :
observablePersistIndexedDB ({
databaseName: 'MyApp' , // Required
version: 1 , // Required: increment to trigger upgrades
tableNames: [ 'table1' , 'table2' ], // Tables to create
deleteTableNames: [ 'oldTable' ], // Tables to delete on upgrade
onUpgradeNeeded : ( event : IDBVersionChangeEvent ) => {
// Custom upgrade logic
const db = event . target . result
// Manually create/modify tables
}
})
Advanced IndexedDB Usage :
// Use prefixID for multiple instances per table
const userData$ = observable ( synced ({
persist: {
name: 'users' ,
plugin: idbPlugin ,
indexedDB: {
prefixID: 'currentUser' , // Namespace within table
itemID: 'profile' // Specific item key
}
}
}))
Characteristics :
Max storage: Hundreds of MBs to GBs
Asynchronous API
Structured data with indexes
Best for large datasets
Automatic ID-based storage
React Native Plugins
MMKV
Fast, encrypted key-value storage for React Native:
import { observablePersistMMKV } from '@legendapp/state/persist-plugins/mmkv'
// Configure MMKV plugin
const mmkvPlugin = observablePersistMMKV ({
id: 'AppStorage'
})
const state$ = observable ( synced ({
persist: {
name: 'userData' ,
plugin: mmkvPlugin
}
}))
Configuration Options :
observablePersistMMKV ({
id: 'AppStorage' , // Storage instance ID
encryptionKey: 'my-encryption-key' , // Optional encryption
path: '/custom/path' // Custom storage path
})
// Per-observable MMKV config
const data$ = observable ( synced ({
persist: {
name: 'data' ,
plugin: mmkvPlugin ,
mmkv: { id: 'SecureStorage' , encryptionKey: 'secret' }
}
}))
Characteristics :
Extremely fast (faster than AsyncStorage)
Encryption support
Synchronous API
Up to 1MB per value
Optimized for React Native
MMKV is the recommended storage for React Native apps due to its performance and encryption capabilities.
AsyncStorage
Standard async key-value storage:
import AsyncStorage from '@react-native-async-storage/async-storage'
import { ObservablePersistAsyncStorage } from '@legendapp/state/persist-plugins/async-storage'
const asyncPlugin = new ObservablePersistAsyncStorage ({
AsyncStorage ,
preload: true // Load all data at initialization
})
const settings$ = observable ( synced ({
persist: {
name: 'settings' ,
plugin: asyncPlugin
}
}))
Configuration Options :
new ObservablePersistAsyncStorage ({
AsyncStorage: AsyncStorage , // Required: AsyncStorage instance
preload: true , // Preload all keys at init
// OR
preload: [ 'key1' , 'key2' ] // Preload specific keys only
})
Characteristics :
Asynchronous API
Standard React Native storage
Slower than MMKV
Good compatibility
Expo SQLite
SQLite database for Expo apps:
import { observablePersistExpoSQLite } from '@legendapp/state/persist-plugins/expo-sqlite'
const sqlitePlugin = observablePersistExpoSQLite ({
database: 'app.db'
})
const data$ = observable ( synced ({
persist: {
name: 'data' ,
plugin: sqlitePlugin
}
}))
Characteristics :
Full SQL database
Complex queries support
Relational data
Good for structured data
Global Plugin Configuration
Set a default plugin for all synced observables:
import { configureObservableSync } from '@legendapp/state/sync'
import { ObservablePersistLocalStorage } from '@legendapp/state/persist-plugins/local-storage'
configureObservableSync ({
persist: {
plugin: ObservablePersistLocalStorage
}
})
// Now all synced observables use localStorage by default
const data$ = observable ( synced ({
persist: { name: 'data' } // No need to specify plugin
}))
Global Options :
configureObservableSync ({
persist: {
plugin: ObservablePersistLocalStorage ,
onGetError : ( error ) => {
console . error ( 'Failed to load from cache:' , error )
},
onSetError : ( error ) => {
console . error ( 'Failed to save to cache:' , error )
}
}
})
Persistence Features
Automatic Saving
Changes are saved automatically with no manual intervention:
const state$ = observable ( synced ({
persist: { name: 'state' },
initial: { count: 0 }
}))
// Automatically saved to storage
state$ . count . set ( 42 )
Incremental Updates
Only changed values are written to storage:
const user$ = observable ( synced ({
persist: { name: 'user' },
initial: { name: '' , email: '' , age: 0 }
}))
// Only 'name' field is written to storage
user$ . name . set ( 'Alice' )
Transform data when loading/saving to storage:
const data$ = observable ( synced ({
persist: {
name: 'data' ,
transform: {
load : ( value ) => {
// Deserialize dates
return {
... value ,
lastSeen: new Date ( value . lastSeen )
}
},
save : ( value ) => {
// Serialize dates
return {
... value ,
lastSeen: value . lastSeen . toISOString ()
}
}
}
}
}))
Read-Only Cache
Load from cache but don’t save changes:
const data$ = observable ( synced ({
persist: {
name: 'data' ,
readonly: true // Won't write to storage
},
get : async () => fetchFromServer ()
}))
Each persisted observable has associated metadata:
interface PersistMetadata {
lastSync ?: number // Timestamp of last successful sync
pending ?: PendingChanges // Unsaved changes for retry
}
Metadata is stored separately with suffix __m:
// Data stored at key: "userData"
// Metadata stored at key: "userData__m"
const userData$ = observable ( synced ({
persist: { name: 'userData' , retrySync: true }
}))
Retry Sync
Enable retrySync to persist pending changes across app restarts:
const data$ = observable ( synced ({
persist: {
name: 'data' ,
retrySync: true // Critical for reliability
},
set : async ({ value }) => saveToServer ( value )
}))
How it works :
Change Made
User modifies data, change saved to local cache
Mark Pending
Change is marked as pending in metadata
Sync Attempt
Attempt to sync to remote server
App Closes
If sync fails and app closes, pending changes are still in metadata
App Reopens
On restart, Legend-State loads pending changes from metadata
Retry Sync
Pending changes are automatically retried
Always use retrySync: true for critical data that must be saved. Without it, failed syncs are lost on app restart.
Managing Cache
Clear Cache
Manually clear persisted data:
import { syncState } from '@legendapp/state'
const data$ = observable ( synced ({ persist: { name: 'data' } }))
// Clear cache and metadata
await syncState ( data$ ). resetPersistence ()
Check Cache Status
const state$ = syncState ( data$ )
// Has cache loaded?
if ( state$ . isPersistLoaded . get ()) {
console . log ( 'Cache loaded' )
}
// Number of pending local loads
state$ . numPendingLocalLoads . get ()
Best Practices
Choose the Right Plugin
Use localStorage for simple settings, preferences, and small state.
Web: Large Data or Complex Queries
Use IndexedDB for large datasets, offline-first apps, or when you need structured storage.
React Native: Performance Critical
Use MMKV for fast, encrypted storage. Best choice for most React Native apps.
Use AsyncStorage for compatibility or when MMKV isn’t available.
Use SQLite (Expo or custom) when you need SQL queries and relations.
Unique Names
Always use unique name values for each synced observable to prevent cache collisions.
// Good
const users$ = synced ({ persist: { name: 'users' } })
const posts$ = synced ({ persist: { name: 'posts' } })
// Bad - same name will cause conflicts
const data1$ = synced ({ persist: { name: 'data' } })
const data2$ = synced ({ persist: { name: 'data' } })
Initialize Plugins Once
// Create plugin instance once
const idbPlugin = observablePersistIndexedDB ({
databaseName: 'MyApp' ,
version: 1 ,
tableNames: [ 'users' , 'posts' ]
})
// Reuse across observables
const users$ = synced ({ persist: { name: 'users' , plugin: idbPlugin } })
const posts$ = synced ({ persist: { name: 'posts' , plugin: idbPlugin } })
Handle Errors
import { configureObservableSync } from '@legendapp/state/sync'
configureObservableSync ({
persist: {
plugin: ObservablePersistLocalStorage ,
onGetError : ( error ) => {
// Handle cache load errors
console . error ( 'Cache load failed:' , error )
// Maybe clear corrupted cache
},
onSetError : ( error ) => {
// Handle cache save errors
console . error ( 'Cache save failed:' , error )
// Maybe notify user storage is full
}
}
})
Migrate Data
Handle schema changes with transforms:
const data$ = observable ( synced ({
persist: {
name: 'data' ,
transform: {
load : ( value ) => {
// Migrate old format to new
if ( ! value . version ) {
return {
... value ,
version: 2 ,
newField: 'default'
}
}
return value
}
}
}
}))
Web App
import { configureObservableSync } from '@legendapp/state/sync'
import { observablePersistIndexedDB } from '@legendapp/state/persist-plugins/indexeddb'
// Configure IndexedDB for large data
const idbPlugin = observablePersistIndexedDB ({
databaseName: 'MyWebApp' ,
version: 1 ,
tableNames: [ 'settings' , 'cache' , 'offline-queue' ]
})
configureObservableSync ({
persist: { plugin: idbPlugin }
})
React Native App
import { configureObservableSync } from '@legendapp/state/sync'
import { observablePersistMMKV } from '@legendapp/state/persist-plugins/mmkv'
// Configure MMKV for fast, encrypted storage
const mmkvPlugin = observablePersistMMKV ({
id: 'AppStorage' ,
encryptionKey: process . env . ENCRYPTION_KEY
})
configureObservableSync ({
persist: { plugin: mmkvPlugin }
})
Expo App
import AsyncStorage from '@react-native-async-storage/async-storage'
import { ObservablePersistAsyncStorage } from '@legendapp/state/persist-plugins/async-storage'
import { configureObservableSync } from '@legendapp/state/sync'
const asyncPlugin = new ObservablePersistAsyncStorage ({
AsyncStorage ,
preload: true
})
configureObservableSync ({
persist: { plugin: asyncPlugin }
})
Troubleshooting
Storage Full Error
configureObservableSync ({
persist: {
onSetError : ( error ) => {
if ( error . message . includes ( 'QuotaExceededError' )) {
// Storage is full
alert ( 'Device storage is full. Please free up space.' )
}
}
}
})
Corrupted Cache
import { syncState } from '@legendapp/state'
const data$ = observable ( synced ({
persist: { name: 'data' },
onError : async ( error ) => {
if ( error . message . includes ( 'parse' )) {
// Cache corrupted, clear it
await syncState ( data$ ). resetPersistence ()
// Reload from server
await syncState ( data$ ). sync ()
}
}
}))
See Also