Persistence Guide

These examples use `createStore`:

createStore builds a store instance immediately and is still fully supported. In application code prefer defineStore, which returns an accessor you call — it is safe at module scope on a server and needs no container passed around. Everything on this page applies to both.

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 four 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)
  }
});

For a small piece of state the server should also see, such as a theme or locale. The cookie travels with every request, so persist only what the server needs:

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

const store = createStore('prefs', {
  state: () => ({ theme: 'light', recentSearches: [] }),
  persist: {
    adapter: new CookieAdapter('prefs', { maxAge: 60 * 60 * 24 * 365 }),
    include: ['theme'],
  }
});

A cookie holds at most 4096 bytes. A write that would exceed that throws, and the store reports it through onError while the previous cookie stays in place.

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. serialize receives the whole stored envelope, { data, version, timestamp, storeName }, not only the state, and deserialize must return that shape. A JSON replacer and reviver pair, as here, handles that naturally.

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) => {
      // Stored data that fails the validator is not loaded, so the store
      // keeps its defaults. Remove it so the next visit starts clean.
      if (operation === 'read') store.$persist?.clear();
    }
  }
});

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
          }
        };
      }
    }
  }
});

Common Migration Patterns

A migration is a plain function from the data as stored under the previous version to the new shape. The usual changes take a line or two each:

const store = createStore('patterns', {
  state: () => ({
    currentUser: null,
    settings: { theme: 'light', language: 'en' }
  }),
  persist: {
    adapter: new LocalStorageAdapter('patterns-store'),
    version: 4,
    migrations: {
      // 1 → 2: add a property
      2: (data) => ({ ...data, settings: { language: 'en', ...data.settings } }),
      // 2 → 3: rename a property
      3: ({ user, ...rest }) => ({ ...rest, currentUser: user }),
      // 3 → 4: transform a property
      4: (data) => ({
        ...data,
        settings: {
          ...data.settings,
          theme: data.settings.theme === 'auto' ? 'light' : data.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
      }
    }
  }
});

// Wait for restoration to finish.
await store.$hydrated;
console.log('Store has been hydrated from storage');

$hydrated is a promise, so there is nothing to poll. It resolves immediately when the store has no persistence configured, which means calling code does not have to branch on whether persistence is on. For a synchronous check, store.$persist?.isRehydrated() is still available.

Cross-tab Synchronization

Automatic Synchronization

The LocalStorageAdapter automatically synchronizes data across browser tabs via storage events:

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'

Note: Cross-tab synchronization is only supported with LocalStorageAdapter. SessionStorageAdapter does not support it because storage events do not fire for same-tab sessionStorage changes. IndexedDBAdapter does not have a built-in change notification mechanism.

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) => {
      // Stored data that fails the validator is not loaded, so the store
      // keeps its defaults. Remove it so the next visit starts clean.
      if (operation === 'read') store.$persist?.clear();
    }
  }
});

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

Spot something that needs improving?

Edit page