Back to QuantaJS Blog

Wednesday, August 27, 2025

Persistence in Real-World Projects: A Practical Guide

Cover image for Persistence in Real-World Projects: A Practical Guide

Persistence in Real-World Projects: A Practical Guide

Persistence is the ability to save selected store state and restore it later. In QuantaJS 3.x, persistence is configured on a defineStore definition, so the same definition can resolve into the right container in a browser, a component-local scope, or a server request.

Real-World Scenarios

1. User Preferences

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

export const usePreferencesStore = defineStore('user-preferences', {
  state: () => ({
    theme: 'light' as 'light' | 'dark',
    language: 'en',
    notifications: true,
  }),
  actions: {
    setTheme(theme: 'light' | 'dark') {
      this.theme = theme;
    },
    setLanguage(language: string) {
      this.language = language;
    },
  },
  persist: {
    adapter: new LocalStorageAdapter('user-preferences'),
    include: ['theme', 'language', 'notifications'],
    debounceMs: 300,
  },
});

A React component can subscribe only to the persisted value it renders:

import { useQuantaActions, useQuantaValue } from '@quantajs/react';

function ThemePicker() {
  const theme = useQuantaValue(usePreferencesStore, (state) => state.theme);
  const preferences = useQuantaActions(usePreferencesStore);

  return (
    <button onClick={() => preferences.setTheme(theme === 'light' ? 'dark' : 'light')}>
      Theme: {theme}
    </button>
  );
}

2. Shopping Cart

const useCartStore = defineStore('shopping-cart', {
  state: () => ({
    items: [] as Array<{ id: string; price: number; quantity: number }>,
  }),
  getters: {
    total: (state) =>
      state.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
  },
  actions: {
    addItem(item: { id: string; price: number }) {
      const current = this.items.find((entry) => entry.id === item.id);
      if (current) current.quantity++;
      else this.items.push({ ...item, quantity: 1 });
    },
    clear() {
      this.items = [];
    },
  },
  persist: {
    adapter: new LocalStorageAdapter('shopping-cart'),
    include: ['items'],
    debounceMs: 500,
  },
});

Derived values such as total do not need their own persisted field; they can be recomputed from persisted state.

3. Multi-Step Form Draft

A draft often belongs to one mounted form rather than the entire application. Keep the definition at module scope and use useLocalStore for component ownership:

import { defineStore, LocalStorageAdapter } from '@quantajs/core';
import { useLocalStore } from '@quantajs/react';

const useFormDraftStore = defineStore('contact-form-draft', {
  state: () => ({
    name: '',
    email: '',
    message: '',
    step: 1,
  }),
  actions: {
    nextStep() {
      if (this.step < 3) this.step++;
    },
  },
  persist: {
    adapter: new LocalStorageAdapter('contact-form-draft'),
    debounceMs: 150,
  },
});

function ContactForm() {
  const form = useLocalStore(useFormDraftStore);
  return <button onClick={() => form.nextStep()}>Step {form.step}</button>;
}

4. Application View State

Persist durable preferences, not short-lived request or cache state:

const useAppStateStore = defineStore('app-state', {
  state: () => ({
    currentView: 'dashboard',
    filters: { category: 'all', status: 'active' },
    sidebarCollapsed: false,
    temporaryResults: [] as string[],
  }),
  persist: {
    adapter: new LocalStorageAdapter('app-state'),
    include: ['currentView', 'filters', 'sidebarCollapsed'],
    debounceMs: 200,
  },
});

5. State the Server Also Needs: CookieAdapter

QuantaJS 3.x adds CookieAdapter. Use it for small state the server should receive with the next request, such as a theme or locale:

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

export const useLocaleStore = defineStore('locale', {
  state: () => ({
    locale: 'en',
    recentSearches: [] as string[],
  }),
  persist: {
    adapter: new CookieAdapter('locale', {
      maxAge: 60 * 60 * 24 * 365,
    }),
    include: ['locale'],
  },
});

Cookies travel with every request and have a small size limit, so keep this state intentionally tiny.

Interactive Demos

The interactive examples below use the current QuantaJS persistence implementation:

QuantaJS Persistence: Live Playground

Test real-world state persistence—no setup, just mutate and reload. QuantaJS auto-saves to localStorage (or adapters like IndexedDB) with zero boilerplate.

Use CaseWhy QuantaJS?Demo
User Preferences (e.g., themes, locales)Persists across sessions; survives browser updates. Scalable for 100s of settings.Theme Switcher
E-commerce CartDeep mutations (e.g., qty updates) auto-save with debounce; include only essentials to avoid bloat.Shopping Cart
Multi-Step Forms (e.g., onboarding)Transform data (e.g., clean empties); resume interrupted flows seamlessly.Contact Form
App UI State (e.g., dashboards)Exclude transients like pagination; nested filters persist without full rehydrate.App State
Multi-Tab Sync (e.g., collaborative tools)Storage events trigger live updates; framework-agnostic for PWAs.Cross-Tab Sync

Pro Tip: Inspect localStorage in DevTools to see auto-saves in action.

QuantaJS handles hydration on init + debounced saves. Scale to IndexedDB for larger state.

Choosing an Adapter

QuantaJS provides adapters for different lifetimes and data sizes:

  • LocalStorageAdapter — durable browser preferences and small application state.
  • SessionStorageAdapter — state that should disappear with the browser session.
  • IndexedDBAdapter — larger browser-side datasets.
  • CookieAdapter — small values the server also needs to read.

Choose the narrowest storage mechanism that matches the state rather than persisting everything by default.

Selective Persistence

const useWorkspaceStore = defineStore('workspace', {
  state: () => ({
    user: null as { id: string; name: string } | null,
    settings: { theme: 'light' },
    temporaryData: [] as string[],
    analyticsBuffer: [] as unknown[],
  }),
  persist: {
    adapter: new LocalStorageAdapter('workspace'),
    include: ['user', 'settings'],
  },
});

Persisting less data makes migrations simpler and reduces storage writes.

Debouncing Writes

const useEditorStore = defineStore('editor', {
  state: () => ({ draft: '' }),
  persist: {
    adapter: new LocalStorageAdapter('editor'),
    debounceMs: 500,
  },
});

Rapid edits are coalesced instead of writing storage after every keystroke.

Schema Migrations

Persisted state outlives individual releases, so version schema changes explicitly:

const useSettingsStore = defineStore('settings', {
  state: () => ({
    theme: 'light',
    language: 'en',
    notifications: true,
  }),
  persist: {
    adapter: new LocalStorageAdapter('settings'),
    version: 3,
    migrations: {
      2: (data) => ({
        ...data,
        language: data.language ?? 'en',
      }),
      3: (data) => ({
        ...data,
        notifications: data.notifications ?? true,
      }),
    },
  },
});

Each migration receives the previous stored data and returns the next shape.

Validation and Error Handling

Use a validator when stale or externally modified data must not enter your store:

const useSessionStore = defineStore('session', {
  state: () => ({
    user: null as null | { id: string },
  }),
  persist: {
    adapter: new LocalStorageAdapter('session'),
    validator: ({ user }) =>
      user === null ||
      (typeof user === 'object' && 'id' in user && typeof user.id === 'string'),
    onError: (error, operation) => {
      console.error(`Persistence ${operation} failed`, error);
    },
  },
});

If restoration fails, the store keeps its defaults instead of making persistence failure fatal to the application.

Waiting for Hydration

A persisted store exposes $hydrated:

const store = useSettingsStore();
await store.$hydrated;
console.log(store.theme);

The promise resolves when restoration has finished. It also resolves immediately for a store without persistence, so calling code does not need a separate branch.

In React, resolve the same definition through useQuanta or useQuantaValue rather than calling it against the ambient container inside a component.

Cross-Tab Synchronization

LocalStorageAdapter can synchronize changes through browser storage events. This is useful for preferences such as theme or authentication-adjacent UI state that should remain consistent across tabs.

Do not assume every adapter has the same synchronization semantics: session storage is tab-scoped, and IndexedDB does not provide LocalStorage-style events automatically.

Server Rendering

Storage adapters are safe to construct in server-rendered modules. For user-specific server state, still follow the QuantaJS container rule: create a container per request and resolve store definitions against that container.

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

export async function loadRequestState() {
  const container = createContainer();

  try {
    const settings = useSettingsStore(container);
    await settings.$hydrated;
    return container.dehydrate();
  } finally {
    container.dispose();
  }
}

Never rely on one process-wide ambient store for request-specific data.

Testing Persistence

Keep tests isolated by creating a fresh container or local store for each case, and use a fake adapter when you want deterministic storage behavior.

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

test('updates persisted settings', async () => {
  const container = createContainer();

  try {
    const settings = useSettingsStore(container);
    await settings.$hydrated;

    settings.theme = 'dark';
    await settings.$persist?.save();

    expect(settings.theme).toBe('dark');
  } finally {
    container.dispose();
  }
});

Best Practices

  • Persist only state that needs to survive its normal lifecycle.
  • Use include or exclude instead of saving caches and transient data.
  • Debounce write-heavy state such as drafts.
  • Version persisted schemas and add migrations before changing their shape.
  • Validate data that may be stale or user-modified.
  • Use CookieAdapter only for small values the server needs.
  • Keep store definitions at module scope.
  • Use useQuantaValue for narrow React subscriptions.
  • Use useQuantaActions for dispatch-only components.
  • Use useLocalStore for component-owned stores.
  • Create a container per server request.

Getting Started

A minimal durable store in QuantaJS 3.x is only a definition plus a persistence adapter:

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

export const useMyStore = defineStore('my-store', {
  state: () => ({
    value: '',
  }),
  persist: {
    adapter: new LocalStorageAdapter('my-store'),
    debounceMs: 300,
  },
});

Learn More

Conclusion

QuantaJS 3.x persistence works best when storage ownership is explicit: define the store once, resolve it in the correct container, and persist only the state that must outlive that container.

That keeps browser state durable without making persistence the source of truth for everything in the application.