State Persistence
State persistence allows your application to maintain state across browser sessions, page refreshes, and different tabs. Reatom’s persist system provides a flexible and powerful way to save and restore atom state using various storage backends with automatic fallbacks and cross-tab synchronization.
Quick Start
Section titled “Quick Start”The simplest way to add persistence is using one of the built-in web storage adapters:
import { atom, withLocalStorage, withSessionStorage, withBroadcastChannel,} from '@reatom/core'
// Persistent across browser sessionsconst userPrefsAtom = atom({ theme: 'light' }, 'userPrefs').extend( withLocalStorage('user-preferences'),)
// Session-only persistenceconst tempDataAtom = atom({}, 'tempData').extend( withSessionStorage('temp-data'),)
// Real-time cross-tab sync (no persistence)const notificationCountAtom = atom(0, 'notificationCount').extend( withBroadcastChannel('notification-count'),)
// Values are automatically saved and restoreduserPrefsAtom.set({ theme: 'dark' })// After page refresh, userPrefsAtom() will return { theme: 'dark' }Web Storage Adapters
Section titled “Web Storage Adapters”Reatom provides ready-to-use adapters for all browser storage APIs with automatic fallbacks to memory storage when unavailable.
localStorage & sessionStorage
Section titled “localStorage & sessionStorage”Perfect for persistent and session-based storage with cross-tab synchronization:
import { withLocalStorage, withSessionStorage,} from '@reatom/core/persist/web-storage'
// Persistent storage (survives browser restarts)const settingsAtom = atom({}, 'settings').extend( withLocalStorage('app-settings'),)
// Session storage (cleared when tab closes)const wizardStateAtom = atom({ step: 1 }, 'wizardState').extend( withSessionStorage('wizard-progress'),)
// Custom configuration with all persist optionsconst configuredAtom = atom('default', 'configured').extend( withLocalStorage({ key: 'my-data', version: 2, migration: (record) => { if (record.version === 1) { return `migrated-${record.data}` } return record.data }, time: 24 * 60 * 60 * 1000, // 24 hours TTL toSnapshot: (state) => state.toUpperCase(), fromSnapshot: (snapshot) => snapshot.toLowerCase(), }),)Features:
- ~5-10MB storage limit (varies by browser)
- Automatic cross-tab synchronization via storage events
- Automatic fallback to memory storage when unavailable
- Memory cache for optimal performance
Use Cases:
- User preferences and settings
- Application state that should persist
- Form data preservation
- Cross-tab data synchronization
BroadcastChannel
Section titled “BroadcastChannel”Real-time cross-tab synchronization without persistent storage:
import { withBroadcastChannel, reatomPersistBroadcastChannel,} from '@reatom/core/persist/web-storage'
// Default channel with automatic fallbackconst liveCounterAtom = atom(0, 'liveCounter').extend( withBroadcastChannel('shared-counter'),)
// Custom channel for specific use casesconst gameChannel = new BroadcastChannel('game-state')const withGameChannel = reatomPersistBroadcastChannel(gameChannel)
const gameStateAtom = atom({}, 'gameState').extend(withGameChannel('game-data'))
// Multiple atoms can share the same channelconst messagesAtom = atom([], 'messages').extend(withGameChannel('messages'))const usersAtom = atom([], 'users').extend(withGameChannel('users'))Features:
- Instant cross-tab synchronization without page refresh
- Memory-based storage (no disk persistence)
- Zero configuration required
- Works across browser tabs and web workers
Use Cases:
- Live notifications and counters
- Real-time collaborative features
- Multi-tab form synchronization
- Live status indicators
Limitations:
- Data doesn’t persist between browser sessions
- Limited to same-origin tabs only
- Not available in all browsers/contexts
Cookies
Section titled “Cookies”Server-side compatible persistence with full HTTP cookie attributes:
import { withCookie } from '@reatom/core/persist/web-storage'
// Basic cookie usageconst themeAtom = atom('light', 'theme').extend( withCookie()('theme-preference'),)
// Cookie with full configurationconst authTokenAtom = atom('', 'authToken').extend( withCookie({ maxAge: 30 * 24 * 60 * 60, // 30 days in seconds path: '/', domain: '.example.com', secure: true, sameSite: 'strict', })('auth-token'),)
// Session cookie (expires when browser closes)const cartAtom = atom([], 'cart').extend(withCookie()('shopping-cart'))Features:
- Server-side accessible via HTTP headers
- Supports all standard cookie attributes
- Automatic JSON serialization and URL encoding
- Memory cache for performance optimization
- Graceful error handling for disabled cookies
Use Cases:
- Authentication tokens
- User preferences accessible from server
- Cross-domain data sharing
- SSR-compatible state
Security Notes:
- Use
secure: truefor sensitive data in production - Consider
sameSite: 'strict'for enhanced CSRF protection - Avoid storing large objects due to 4KB size limit
Cookie Store API (Modern Async Cookies)
Section titled “Cookie Store API (Modern Async Cookies)”Modern asynchronous cookie management using the Cookie Store API with automatic cross-tab synchronization:
import { withCookieStore } from '@reatom/core/persist/web-storage'
// Basic usage with modern async APIconst themeAtom = atom('light', 'theme').extend( withCookieStore()('theme-preference'),)
// Async cookie with full configurationconst sessionAtom = atom(null, 'session').extend( withCookieStore({ expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 days in ms path: '/', sameSite: 'strict', })('session-id'),)
// Secure authentication cookieconst authTokenAtom = atom('', 'authToken').extend( withCookieStore({ expires: Date.now() + 30 * 24 * 60 * 60 * 1000, // 30 days sameSite: 'strict', path: '/', })('auth-token'),)Features:
- Asynchronous promise-based API for better performance
- Automatic cross-tab synchronization via change events
- Better error handling than document.cookie
- Available in service workers
- Non-blocking operations
- Automatic fallback to memory storage when unavailable
Browser Support:
- Chrome/Edge 87+ and Chromium-based browsers
- Limited browser support (not available in Firefox/Safari yet)
- Automatic fallback ensures compatibility in all browsers
Use Cases:
- Modern web applications requiring async cookie operations
- Service worker cookie management
- Applications prioritizing non-blocking I/O
- Cross-tab synchronized cookie state
Advantages over document.cookie:
- Non-blocking asynchronous operations
- Automatic change notifications
- Works in service workers
- Better error messages
- Cleaner API design
When to use Cookie Store API vs Cookies:
- Use withCookieStore: Modern browsers, service workers, async-first apps
- Use withCookie: Maximum browser compatibility, SSR applications, synchronous needs
IndexedDB
Section titled “IndexedDB”Large-capacity persistent storage for complex applications:
// First install the peer dependency:// npm install idb-keyval
import { withIndexedDb, reatomPersistIndexedDb,} from '@reatom/core/persist/web-storage'
// Default IndexedDB with automatic fallbackconst largeDataAtom = atom(new Map(), 'largeData').extend( withIndexedDb('large-dataset'),)
// Custom database configurationconst userDataChannel = new BroadcastChannel('user-data-sync')const withUserDb = reatomPersistIndexedDb('user-database', userDataChannel)
const profileAtom = atom({}, 'profile').extend(withUserDb('user-profile'))
// Large data that exceeds localStorage limitsconst cacheAtom = atom([], 'cache').extend(withIndexedDb('api-cache'))Features:
- Large storage capacity (hundreds of MB to GB)
- Persistent storage that survives browser restarts
- Cross-tab synchronization via BroadcastChannel
- Asynchronous operations with immediate memory access
- Automatic fallback to memory storage when unavailable
Requirements:
- Install
idb-keyvalas peerDependency:npm install idb-keyval - Modern browser with IndexedDB and BroadcastChannel support
Use Cases:
- Large application data and caches
- Offline-first applications
- Complex data that exceeds localStorage limits
- Applications needing database-like storage features
Comparison with other storage:
- vs localStorage: Much larger capacity, better for complex data
- vs sessionStorage: Persists between browser sessions
- vs cookies: No size limits, not sent with HTTP requests
- vs BroadcastChannel: Persistent storage, not just cross-tab sync
Basic Persist API
Section titled “Basic Persist API”For custom storage implementations or when you need more control:
import { atom } from '@reatom/core'import { createMemStorage, reatomPersist } from '@reatom/core/persist'
// Create a storage backendconst storage = createMemStorage({ name: 'my-app', snapshot: { // Optional: pre-populate with data 'user-name': 'John Doe', theme: 'dark', },})
const withPersist = reatomPersist(storage)
// Create persistent atomsconst counterAtom = atom(0, 'counter').extend(withPersist('counter-key'))const userAtom = atom('', 'user').extend(withPersist('user-key'))Configuration Options
Section titled “Configuration Options”All persist adapters support the same configuration options:
// Simple key usageconst simpleAtom = atom(0).extend(withLocalStorage('my-key'))
// Full configuration objectconst configuredAtom = atom({ name: '', age: 0 }).extend( withLocalStorage({ key: 'user-data',
// Custom serialization toSnapshot: (state) => ({ n: state.name, a: state.age, }), fromSnapshot: (snapshot: any) => ({ name: snapshot.n, age: snapshot.a, }),
// Version migration version: 2, migration: (record) => { if (record.version === 1) { // Migrate from v1 to v2 return { name: record.data.userName, age: record.data.userAge } } return record.data },
// TTL (time to live) in milliseconds time: 24 * 60 * 60 * 1000, // 24 hours
// Storage subscription for cross-tab sync subscribe: true, // Default: true if storage supports it }),)Configuration Reference
Section titled “Configuration Reference”| Option | Type | Default | Description |
|---|---|---|---|
key | string | required | Unique key for storage |
toSnapshot | (state) => any | identity | Serialize state before saving |
fromSnapshot | (snapshot) => state | identity | Deserialize state after loading |
version | number | 0 | Version number for migration |
migration | (record) => state | undefined | Migrate old data to current version |
time | number | MAX_SAFE_TIMEOUT | TTL in milliseconds |
subscribe | boolean | true | Enable cross-tab synchronization |
Cross-Tab Synchronization
Section titled “Cross-Tab Synchronization”When multiple atoms share the same storage key, they automatically stay synchronized across browser tabs:
// Tab 1const userNameAtom1 = atom('').extend(withLocalStorage('user-name'))
// Tab 2const userNameAtom2 = atom('').extend(withLocalStorage('user-name'))
// When userNameAtom1 changes in Tab 1, userNameAtom2 in Tab 2 updates automaticallyuserNameAtom1.set('Alice') // Both tabs now show 'Alice'This works through storage subscriptions (enabled by default). You can disable it by setting subscribe: false.
Version Migration
Section titled “Version Migration”Handle data format changes gracefully with version migration:
const userAtom = atom({ name: '', preferences: {} }).extend( withLocalStorage({ key: 'user', version: 3, migration: (record) => { const data = record.data
// Migrate from v1: { userName } -> { name, preferences } if (record.version === 1) { return { name: data.userName, preferences: {} } }
// Migrate from v2: add default preferences if (record.version === 2) { return { ...data, preferences: { theme: 'light' } } }
return data }, }),)Custom Serialization
Section titled “Custom Serialization”Control exactly what gets saved and how with type-safe validation using Zod:
import { z } from 'zod'
// Define schemas for validationconst PreferencesSchema = z.object({ theme: z.enum(['light', 'dark']), language: z.string().optional(),})
const FormSnapshotSchema = z.object({ email: z.string().email(), preferences: PreferencesSchema,})
// Type-safe form stateinterface FormState { // Persistent fields email: string preferences: z.infer<typeof PreferencesSchema>
// Temporary fields (not persisted) isSubmitting: boolean errors: string[]}
const initalState: FormState = { email: '', preferences: { theme: 'dark' }, isSubmitting: false, errors: [],}
const formAtom = atom(initialState).extend( withLocalStorage({ key: 'form-data', toSnapshot: (state) => ({ email: state.email, preferences: state.preferences, }), fromSnapshot: (snapshot: unknown) => { try { // Validate and parse the snapshot with Zod const validated = FormSnapshotSchema.parse(snapshot) return { email: validated.email, preferences: validated.preferences, isSubmitting: false, errors: [], } } catch (error) { // If validation fails, return default state console.warn('Invalid persisted data, using defaults:', error) return initialState } }, }),)
// Advanced: Migration with Zod schemasconst FormSnapshotV1Schema = z.object({ userEmail: z.string(), theme: z.string(),})
const initalState: FormState = { email: '', preferences: { theme: 'dark' }, isSubmitting: false, errors: [],}
const formAtomWithMigration = atom(initialState).extend( withLocalStorage({ key: 'form-data-v2', version: 2, toSnapshot: (state) => ({ email: state.email, preferences: state.preferences, }), fromSnapshot: (snapshot: unknown) => { try { const validated = FormSnapshotSchema.parse(snapshot) return { email: validated.email, preferences: validated.preferences, isSubmitting: false, errors: [], } } catch { return initialState } }, migration: (record) => { if (record.version === 1) { try { // Migrate from v1 format const oldData = FormSnapshotV1Schema.parse(record.data) return { email: oldData.userEmail, preferences: { theme: oldData.theme as 'light' | 'dark' }, } } catch { return { email: '', preferences: { theme: 'dark' } } } } return record.data }, }),)Time-to-Live (TTL)
Section titled “Time-to-Live (TTL)”Automatically expire stored data after a specified time:
// Cache API data for 1 hourconst apiCacheAtom = atom(null).extend( withLocalStorage({ key: 'api-cache', time: 60 * 60 * 1000, // 1 hour in milliseconds }),)
// After 1 hour, the stored data is considered expired// and the atom will use its default valueCustom Storage Implementation
Section titled “Custom Storage Implementation”Create your own storage backends by implementing the PersistStorage interface:
import { PersistStorage } from '@reatom/core/persist'
// Example: Custom localStorage implementationconst createCustomStorage = (name: string): PersistStorage => ({ name, get: (key) => { const item = localStorage.getItem(`${name}:${key}`) return item ? JSON.parse(item) : null }, set: (key, record) => { localStorage.setItem(`${name}:${key}`, JSON.stringify(record)) }, clear: (key) => { localStorage.removeItem(`${name}:${key}`) }, subscribe: (key, callback) => { const handler = (event: StorageEvent) => { if (event.key === `${name}:${key}` && event.newValue) { callback(JSON.parse(event.newValue)) } } window.addEventListener('storage', handler) return () => window.removeEventListener('storage', handler) },})
// Example: async storage (IndexedDB, API, etc.)const createAsyncStorage = (): PersistStorage => ({ name: 'async-storage', get: async (key) => { const response = await fetch(`/api/storage/${key}`) return response.ok ? await response.json() : null }, set: async (key, record) => { await fetch(`/api/storage/${key}`, { method: 'POST', body: JSON.stringify(record), }) },})Storage Interface
Section titled “Storage Interface”The complete PersistStorage interface:
interface PersistStorage { name: string get(key: string): null | PersistRecord | Promise<null | PersistRecord> set(key: string, rec: PersistRecord): void | Promise<void> clear?(key: string): void | Promise<void> subscribe?( key: string, callback: (record: PersistRecord) => void, ): Unsubscribe}
interface PersistRecord { data: any // Your serialized state id: number // Unique record ID timestamp: number // When record was created version: number // Your version number to: number // Expiration timestamp}Error Handling & Graceful Fallbacks
Section titled “Error Handling & Graceful Fallbacks”All persist operations are designed to be non-blocking. If storage fails, your application continues to work:
const robustAtom = atom('default').extend(withLocalStorage('may-fail'))
// If storage.get() throws, atom uses default valueconsole.log(robustAtom()) // 'default'
// If storage.set() throws, atom still updates in memoryrobustAtom.set('new-value')console.log(robustAtom()) // 'new-value'
// Errors are logged to console for debuggingAll web storage adapters automatically fall back to memory storage when:
- Browser APIs are unavailable (SSR, Node.js)
- Storage is disabled (incognito mode, privacy settings)
- Storage quota is exceeded
- Dependencies are missing (e.g., idb-keyval for IndexedDB)
// Works in all environments - graceful fallback to memory storageconst universalAtom = atom('default').extend(withLocalStorage('key'))Best Practices
Section titled “Best Practices”1. Use Descriptive Keys
Section titled “1. Use Descriptive Keys”// ❌ Generic keyswithLocalStorage('data')
// ✅ Descriptive keyswithLocalStorage('user-profile')withLocalStorage('shopping-cart')withLocalStorage('app-settings')2. Version Your Data
Section titled “2. Version Your Data”// Always specify version for production datawithLocalStorage({ key: 'user-preferences', version: 1, // Start with version 1 migration: (record) => { // Handle future migrations here return record.data },})3. Choose the Right Storage Type
Section titled “3. Choose the Right Storage Type”// ✅ Persistent user preferencesconst settingsAtom = atom({}).extend(withLocalStorage('settings'))
// ✅ Session-only form dataconst formAtom = atom({}).extend(withSessionStorage('form-draft'))
// ✅ Real-time cross-tab syncconst notificationsAtom = atom([]).extend(withBroadcastChannel('notifications'))
// ✅ Large datasetsconst cacheAtom = atom(new Map()).extend(withIndexedDb('large-cache'))
// ✅ Server-accessible dataconst tokenAtom = atom('').extend(withCookie({ secure: true })('auth-token'))4. Consider TTL for Cached Data
Section titled “4. Consider TTL for Cached Data”// Cache API responses with reasonable TTLconst apiDataAtom = atom(null).extend( withLocalStorage({ key: 'api-cache', time: 15 * 60 * 1000, // 15 minutes }),)5. Serialize Carefully
Section titled “5. Serialize Carefully”// Only persist what you needwithLocalStorage({ key: 'form', toSnapshot: (state) => ({ // Include: user input email: state.email, name: state.name,
// Exclude: UI state, temporary data // isLoading: state.isLoading, // errors: state.errors }),})Advanced Patterns
Section titled “Advanced Patterns”Conditional Persistence
Section titled “Conditional Persistence”// Only persist when user is logged inconst withConditionalPersist = (key: string) => withLocalStorage({ key, toSnapshot: (state) => { if (!userAtom().isLoggedIn) return null return state }, })Multiple Storage Strategies
Section titled “Multiple Storage Strategies”// Use different storage types for different dataconst userPrefsAtom = atom({}).extend(withLocalStorage('user-prefs')) // Persistentconst formDataAtom = atom({}).extend(withSessionStorage('form-data')) // Session-onlyconst liveStatusAtom = atom({}).extend(withBroadcastChannel('live-status')) // Real-time syncconst largeCacheAtom = atom([]).extend(withIndexedDb('large-cache')) // Big dataconst authTokenAtom = atom('').extend(withCookie({ secure: true })('token')) // Server-accessible (sync)const sessionTokenAtom = atom('').extend(withCookieStore()('session')) // Modern async cookiesWorking with Computed Atoms
Section titled “Working with Computed Atoms”Persist works seamlessly with computed atoms:
import { withComputed } from '@reatom/core'
const baseValueAtom = atom(10).extend(withLocalStorage('base-value'))
const doubledAtom = atom(0).extend(withComputed(() => baseValueAtom() * 2))
// Only baseValueAtom is persisted// doubledAtom is automatically recomputed on restoreThe persist system provides a robust foundation for maintaining state across sessions while remaining flexible enough to handle complex requirements like data migration, custom serialization, and various storage backends. Choose the right storage adapter for your needs and enjoy automatic fallbacks, cross-tab synchronization, and comprehensive error handling.