Wednesday, August 27, 2025
Persistence in Real-World Projects: A Practical Guide
Posted by

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 Case | Why QuantaJS? | Demo |
|---|---|---|
| User Preferences (e.g., themes, locales) | Persists across sessions; survives browser updates. Scalable for 100s of settings. | Theme Switcher |
| E-commerce Cart | Deep 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
includeorexcludeinstead 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
CookieAdapteronly for small values the server needs. - Keep store definitions at module scope.
- Use
useQuantaValuefor narrow React subscriptions. - Use
useQuantaActionsfor dispatch-only components. - Use
useLocalStorefor 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.