Persistence Guide

QuantaJS persistence system allows you to automatically save and restore store state across browser sessions, providing a seamless user experience and offline functionality.

Getting Started

Basic Persistence Setup

The simplest way to add persistence to your store is by adding a persist option:

import { createStore, LocalStorageAdapter } from '@quantajs/core';

const userStore = createStore('user', {
  state: () => ({
    name: '',
    email: '',
    preferences: { theme: 'light' }
  }),
  persist: {
    adapter: new LocalStorageAdapter('user-store')
  }
});

This automatically:

  • Saves state changes to localStorage
  • Restores state when the page reloads
  • Synchronizes data across browser tabs
  • Handles serialization/deserialization

Understanding Storage Adapters

QuantaJS provides three storage adapters for different use cases:

LocalStorage Adapter

Best for most applications - data persists until manually cleared:

import { LocalStorageAdapter } from '@quantajs/core';

const store = createStore('app', {
  state: () => ({ settings: { theme: 'light' } }),
  persist: {
    adapter: new LocalStorageAdapter('app-settings')
  }
});

SessionStorage Adapter

Data persists only for the current browser session:

import { SessionStorageAdapter } from '@quantajs/core';

const store = createStore('session', {
  state: () => ({ temporaryData: [] }),
  persist: {
    adapter: new SessionStorageAdapter('session-data')
  }
});

IndexedDB Adapter

Best for large datasets and complex data structures:

import { IndexedDBAdapter } from '@quantajs/core';

const store = createStore('large', {
  state: () => ({ documents: [] }),
  persist: {
    adapter: new IndexedDBAdapter('documents', 'app-db', 'stores', 1)
  }
});

Advanced Configuration

Selective Persistence

Control which parts of your state are persisted:

const store = createStore('selective', {
  state: () => ({
    user: null,
    settings: { theme: 'light' },
    temporaryData: [],
    cache: new Map()
  }),
  persist: {
    adapter: new LocalStorageAdapter('selective-store'),
    include: ['user', 'settings'], // Only persist these properties
    exclude: ['temporaryData', 'cache'] // Explicitly exclude these
  }
});

Custom Serialization

Handle complex data types that can't be serialized with JSON:

const store = createStore('complex', {
  state: () => ({
    dates: [new Date()],
    functions: [() => console.log('test')],
    customObjects: new CustomClass()
  }),
  persist: {
    adapter: new LocalStorageAdapter('complex-store'),
    serialize: (data) => {
      return JSON.stringify(data, (key, value) => {
        if (value instanceof Date) {
          return { __type: 'Date', value: value.toISOString() };
        }
        if (value instanceof CustomClass) {
          return { __type: 'CustomClass', data: value.serialize() };
        }
        if (typeof value === 'function') {
          return { __type: 'Function', name: value.name };
        }
        return value;
      });
    },
    deserialize: (data) => {
      return JSON.parse(data, (key, value) => {
        if (value && value.__type === 'Date') {
          return new Date(value.value);
        }
        if (value && value.__type === 'CustomClass') {
          return CustomClass.deserialize(value.data);
        }
        return value;
      });
    }
  }
});

Data Validation

Ensure data integrity with validation functions:

const store = createStore('validated', {
  state: () => ({
    user: null,
    settings: { theme: 'light', language: 'en' }
  }),
  persist: {
    adapter: new LocalStorageAdapter('validated-store'),
    validator: (data) => {
      // Validate user data
      if (data.user && typeof data.user.id !== 'string') {
        return false;
      }
      
      // Validate settings
      if (data.settings) {
        const validThemes = ['light', 'dark', 'auto'];
        const validLanguages = ['en', 'es', 'fr'];
        
        if (!validThemes.includes(data.settings.theme)) {
          return false;
        }
        
        if (!validLanguages.includes(data.settings.language)) {
          return false;
        }
      }
      
      return true;
    },
    onError: (error, operation) => {
      if (operation === 'read' && !store.validator(data)) {
        // If validation fails, reset to defaults
        store.$reset();
      }
    }
  }
});

Migration System

Why Migrations?

As your application evolves, your data structure may change. Migrations allow you to update existing persisted data to match new schemas:

const store = createStore('evolving', {
  state: () => ({
    user: null,
    settings: { theme: 'light', language: 'en', notifications: true }
  }),
  persist: {
    adapter: new LocalStorageAdapter('evolving-store'),
    version: 3,
    migrations: {
      2: (data) => {
        // Version 2: Add language setting
        return {
          ...data,
          settings: {
            ...data.settings,
            language: 'en' // Default value for existing users
          }
        };
      },
      3: (data) => {
        // Version 3: Add notifications setting
        return {
          ...data,
          settings: {
            ...data.settings,
            notifications: true // Default value for existing users
          }
        };
      }
    }
  }
});

Using Common Migration Patterns

QuantaJS provides common migration patterns for typical schema changes:

import { CommonMigrations } from '@quantajs/core';

const store = createStore('patterns', {
  state: () => ({
    user: null,
    settings: { theme: 'light', language: 'en' }
  }),
  persist: {
    adapter: new LocalStorageAdapter('patterns-store'),
    version: 4,
    migrations: {
      2: CommonMigrations.addProperty('settings', { language: 'en' }),
      3: CommonMigrations.renameProperty('user', 'currentUser'),
      4: CommonMigrations.transformProperty('settings', (settings) => ({
        ...settings,
        theme: settings.theme === 'auto' ? 'light' : settings.theme
      }))
    }
  }
});

Custom Migration Functions

For complex migrations, create custom functions:

const store = createStore('custom', {
  state: () => ({
    todos: [],
    categories: []
  }),
  persist: {
    adapter: new LocalStorageAdapter('custom-store'),
    version: 2,
    migrations: {
      2: (data) => {
        // Complex migration: restructure todos with categories
        if (data.todos && Array.isArray(data.todos)) {
          const categories = new Set();
          
          // Extract categories from existing todos
          data.todos.forEach(todo => {
            if (todo.category) {
              categories.add(todo.category);
            }
          });
          
          // Create categories array
          data.categories = Array.from(categories).map(name => ({
            id: Date.now() + Math.random(),
            name,
            color: '#007bff'
          }));
          
          // Update todos to reference category IDs
          data.todos = data.todos.map(todo => {
            if (todo.category) {
              const category = data.categories.find(c => c.name === todo.category);
              return {
                ...todo,
                categoryId: category?.id || null,
                category: undefined // Remove old property
              };
            }
            return todo;
          });
        }
        
        return data;
      }
    }
  }
});

Performance Optimization

Debouncing Saves

Prevent excessive storage writes by debouncing save operations:

const store = createStore('performance', {
  state: () => ({ counter: 0 }),
  persist: {
    adapter: new LocalStorageAdapter('performance-store'),
    debounceMs: 1000 // Save only after 1 second of inactivity
  }
});

// Rapid changes will be batched into a single save
for (let i = 0; i < 100; i++) {
  store.counter++;
}
// Only one save operation occurs after 1 second

Selective Persistence

Only persist essential data to improve performance:

const store = createStore('efficient', {
  state: () => ({
    user: null,
    settings: { theme: 'light' },
    temporaryData: [],
    largeCache: new Map(),
    analytics: []
  }),
  persist: {
    adapter: new LocalStorageAdapter('efficient-store'),
    include: ['user', 'settings'], // Only persist critical data
    exclude: ['temporaryData', 'largeCache', 'analytics'] // Exclude large objects
  }
});

Lazy Loading

Load persisted data only when needed:

const store = createStore('lazy', {
  state: () => ({
    user: null,
    isHydrated: false
  }),
  persist: {
    adapter: new LocalStorageAdapter('lazy-store'),
    onError: (error, operation) => {
      if (operation === 'read') {
        store.isHydrated = true; // Mark as hydrated even on error
      }
    }
  }
});

// Check if data has been loaded
if (store.$persist && store.$persist.isRehydrated()) {
  console.log('Store has been hydrated from storage');
}

Cross-tab Synchronization

Automatic Synchronization

All storage adapters automatically synchronize data across browser tabs:

const store = createStore('sync', {
  state: () => ({ theme: 'light' }),
  persist: {
    adapter: new LocalStorageAdapter('sync-store')
  }
});

// Tab 1
store.theme = 'dark';

// Tab 2 automatically receives the update
console.log(store.theme); // 'dark'

Manual Synchronization

For more control, you can manually trigger synchronization:

const store = createStore('manual-sync', {
  state: () => ({ data: [] }),
  persist: {
    adapter: new LocalStorageAdapter('manual-sync-store')
  }
});

// Force a save and sync
await store.$persist.save();

// Force a reload from storage
await store.$persist.load();

Error Handling

Comprehensive Error Handling

Handle different types of persistence errors:

const store = createStore('robust', {
  state: () => ({ data: [] }),
  persist: {
    adapter: new LocalStorageAdapter('robust-store'),
    onError: (error, operation) => {
      switch (operation) {
        case 'read':
          console.warn('Failed to load persisted state, using defaults');
          // Optionally show user notification
          break;
          
        case 'write':
          console.error('Failed to save state:', error);
          if (error.name === 'QuotaExceededError') {
            // Handle storage quota exceeded
            alert('Storage space exceeded. Some data may not be saved.');
          }
          break;
          
        case 'remove':
          console.error('Failed to clear persisted data:', error);
          break;
      }
    }
  }
});

Fallback Strategies

Implement fallback strategies when persistence fails:

const store = createStore('fallback', {
  state: () => ({ settings: { theme: 'light' } }),
  persist: {
    adapter: new LocalStorageAdapter('fallback-store'),
    onError: async (error, operation) => {
      if (operation === 'read') {
        // Try to load from a different storage method
        try {
          const fallbackData = sessionStorage.getItem('fallback-settings');
          if (fallbackData) {
            const parsed = JSON.parse(fallbackData);
            Object.assign(store.settings, parsed);
          }
        } catch (fallbackError) {
          console.warn('Fallback loading also failed:', fallbackError);
        }
      }
    }
  }
});

Real-world Examples

User Preferences Store

const userPreferencesStore = createStore('preferences', {
  state: () => ({
    theme: 'light',
    language: 'en',
    notifications: {
      email: true,
      push: false,
      sms: false
    },
    accessibility: {
      fontSize: 'medium',
      highContrast: false,
      reduceMotion: false
    }
  }),
  persist: {
    adapter: new LocalStorageAdapter('user-preferences'),
    version: 2,
    debounceMs: 500,
    migrations: {
      2: (data) => ({
        ...data,
        accessibility: {
          fontSize: 'medium',
          highContrast: false,
          reduceMotion: false,
          ...data.accessibility
        }
      })
    },
    onError: (error, operation) => {
      if (operation === 'write') {
        // Log to analytics service
        analytics.track('persistence_error', { operation, error: error.message });
      }
    }
  }
});

Shopping Cart Store

const cartStore = createStore('cart', {
  state: () => ({
    items: [],
    appliedCoupons: [],
    shippingAddress: null,
    billingAddress: null
  }),
  persist: {
    adapter: new LocalStorageAdapter('shopping-cart'),
    version: 1,
    debounceMs: 300,
    include: ['items', 'appliedCoupons'], // Don't persist addresses
    validator: (data) => {
      // Ensure items have required properties
      if (data.items && Array.isArray(data.items)) {
        return data.items.every(item => 
          item.id && 
          typeof item.quantity === 'number' && 
          item.quantity > 0
        );
      }
      return true;
    },
    onError: (error, operation) => {
      if (operation === 'read' && !store.validator(data)) {
        // Clear invalid cart data
        store.items = [];
        store.appliedCoupons = [];
      }
    }
  }
});

Application State Store

const appStateStore = createStore('app-state', {
  state: () => ({
    currentRoute: '/',
    sidebarOpen: false,
    modals: [],
    notifications: [],
    user: null,
    permissions: []
  }),
  persist: {
    adapter: new LocalStorageAdapter('app-state'),
    version: 1,
    debounceMs: 1000,
    include: ['currentRoute', 'sidebarOpen', 'user'], // Only persist essential UI state
    exclude: ['modals', 'notifications', 'permissions'], // Don't persist temporary data
    onError: (error, operation) => {
      // Log errors for debugging
      console.error(`App state persistence ${operation} failed:`, error);
      
      // Don't break the app on persistence errors
      if (operation === 'read') {
        // Use default values
        store.currentRoute = '/';
        store.sidebarOpen = false;
      }
    }
  }
});

Testing Persistence

Unit Testing

Test persistence functionality in isolation:

import { createStore, LocalStorageAdapter } from '@quantajs/core';

describe('Persistence', () => {
  let store;
  
  beforeEach(() => {
    // Clear storage before each test
    localStorage.clear();
    
    store = createStore('test', {
      state: () => ({ count: 0 }),
      persist: {
        adapter: new LocalStorageAdapter('test-store')
      }
    });
  });
  
  test('should persist state changes', async () => {
    store.count = 42;
    
    // Wait for debounced save
    await new Promise(resolve => setTimeout(resolve, 400));
    
    // Create new store instance to test loading
    const newStore = createStore('test2', {
      state: () => ({ count: 0 }),
      persist: {
        adapter: new LocalStorageAdapter('test-store')
      }
    });
    
    expect(newStore.count).toBe(42);
  });
  
  test('should handle storage errors gracefully', () => {
    // Mock localStorage to throw error
    const originalSetItem = localStorage.setItem;
    localStorage.setItem = () => { throw new Error('Storage error'); };
    
    expect(() => {
      store.count = 100;
    }).not.toThrow();
    
    // Restore original method
    localStorage.setItem = originalSetItem;
  });
});

Integration Testing

Test persistence with real storage:

describe('Persistence Integration', () => {
  test('should sync across multiple store instances', async () => {
    const store1 = createStore('sync-test', {
      state: () => ({ data: 'initial' }),
      persist: {
        adapter: new LocalStorageAdapter('sync-test-store')
      }
    });
    
    const store2 = createStore('sync-test2', {
      state: () => ({ data: 'initial' }),
      persist: {
        adapter: new LocalStorageAdapter('sync-test-store')
      }
    });
    
    // Update first store
    store1.data = 'updated';
    
    // Wait for persistence
    await new Promise(resolve => setTimeout(resolve, 400));
    
    // Second store should have updated data
    expect(store2.data).toBe('updated');
  });
});

Best Practices

1. Choose the Right Storage Adapter

  • LocalStorage: For most applications, user preferences, settings
  • SessionStorage: For temporary data, form drafts, session state
  • IndexedDB: For large datasets, complex objects, offline data

2. Use Selective Persistence

Only persist essential data to improve performance:

persist: {
  adapter: new LocalStorageAdapter('store'),
  include: ['essential', 'user', 'settings'],
  exclude: ['temporary', 'cache', 'analytics']
}

3. Implement Proper Error Handling

Always provide error handlers for production applications:

persist: {
  adapter: new LocalStorageAdapter('store'),
  onError: (error, operation) => {
    // Log error
    console.error(`Persistence ${operation} failed:`, error);
    
    // Show user notification if appropriate
    if (operation === 'write') {
      showNotification('Failed to save data', 'error');
    }
  }
}

4. Use Migrations for Schema Changes

When updating your data structure, always provide migrations:

persist: {
  adapter: new LocalStorageAdapter('store'),
  version: 2,
  migrations: {
    2: (data) => ({ ...data, newField: 'default' })
  }
}

5. Test Persistence Thoroughly

Test persistence functionality in your test suite:

test('persistence works correctly', async () => {
  store.data = 'test';
  await store.$persist.save();
  
  const newStore = createStore('test', {
    state: () => ({ data: '' }),
    persist: { adapter: new LocalStorageAdapter('test') }
  });
  
  expect(newStore.data).toBe('test');
});

Learn More