PatternsState and Logic
State Persistence Strategies
Save and restore application state across sessions
State persistence saves user data across sessions—essential for offline apps, draft recovery, and seamless UX.
localStorage (Simplest)
// utils/storage.ts
export function saveToLocalStorage<T>(key: string, value: T): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.error('Failed to save to localStorage:', error);
}
}
export function loadFromLocalStorage<T>(key: string): T | null {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : null;
} catch (error) {
console.error('Failed to load from localStorage:', error);
return null;
}
}
export function removeFromLocalStorage(key: string): void {
localStorage.removeItem(key);
}React Hook for Persistence
// hooks/usePersistedState.ts
export function usePersistedState<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
const [state, setState] = useState<T>(() => {
const saved = loadFromLocalStorage<T>(key);
return saved !== null ? saved : initialValue;
});
useEffect(() => {
saveToLocalStorage(key, state);
}, [key, state]);
return [state, setState];
}
// Usage
export function Component() {
const [theme, setTheme] = usePersistedState('theme', 'light');
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
);
}IndexedDB (Large Data)
// db/storage.ts
import { openDB, DBSchema, IDBPDatabase } from 'idb';
interface AppDB extends DBSchema {
drafts: {
key: string;
value: {
id: string;
content: string;
updatedAt: number;
};
};
}
let db: IDBPDatabase<AppDB> | null = null;
async function getDB() {
if (!db) {
db = await openDB<AppDB>('app-storage', 1, {
upgrade(db) {
db.createObjectStore('drafts', { keyPath: 'id' });
},
});
}
return db;
}
export async function saveDraft(id: string, content: string) {
const db = await getDB();
await db.put('drafts', {
id,
content,
updatedAt: Date.now(),
});
}
export async function loadDraft(id: string) {
const db = await getDB();
return db.get('drafts', id);
}
export async function deleteDraft(id: string) {
const db = await getDB();
await db.delete('drafts', id);
}
export async function getAllDrafts() {
const db = await getDB();
return db.getAll('drafts');
}Redux Persistence
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import {
persistStore,
persistReducer,
FLUSH,
REHYDRATE,
PAUSE,
PERSIST,
PURGE,
REGISTER,
} from 'redux-persist';
import storage from 'redux-persist/lib/storage';
const persistConfig = {
key: 'root',
storage,
whitelist: ['user', 'settings'], // Only persist these
blacklist: ['temp'], // Don't persist these
};
const persistedReducer = persistReducer(persistConfig, rootReducer);
export const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}),
});
export const persistor = persistStore(store);
// app/layout.tsx
import { Provider } from 'react-redux';
import { PersistGate } from 'redux-persist/integration/react';
export default function RootLayout({ children }: Props) {
return (
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
{children}
</PersistGate>
</Provider>
);
}Debounced Persistence
// hooks/useDebouncedPersist.ts
export function useDebouncedPersist<T>(
key: string,
value: T,
delay = 1000
) {
const debouncedValue = useDebounce(value, delay);
useEffect(() => {
saveToLocalStorage(key, debouncedValue);
}, [key, debouncedValue]);
}
// Usage
export function Editor() {
const [content, setContent] = useState('');
// Auto-saves after 1 second of inactivity
useDebouncedPersist('draft', content, 1000);
return (
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
/>
);
}Migration Strategy
// utils/migrations.ts
interface PersistedData {
version: number;
data: any;
}
const CURRENT_VERSION = 2;
const migrations = {
1: (data: any) => {
// Migrate v0 → v1
return {
...data,
newField: 'default',
};
},
2: (data: any) => {
// Migrate v1 → v2
return {
...data,
user: {
...data.user,
settings: data.settings, // Move settings under user
},
settings: undefined,
};
},
};
export function migrateData(persisted: PersistedData): any {
let { version, data } = persisted;
while (version < CURRENT_VERSION) {
version++;
data = migrations[version](data);
}
return data;
}
export function saveWithVersion<T>(key: string, data: T): void {
saveToLocalStorage(key, {
version: CURRENT_VERSION,
data,
});
}
export function loadWithMigration<T>(key: string): T | null {
const persisted = loadFromLocalStorage<PersistedData>(key);
if (!persisted) return null;
if (persisted.version < CURRENT_VERSION) {
const migrated = migrateData(persisted);
saveWithVersion(key, migrated);
return migrated;
}
return persisted.data;
}Encryption
// utils/encrypted-storage.ts
import CryptoJS from 'crypto-js';
const SECRET_KEY = process.env.NEXT_PUBLIC_ENCRYPTION_KEY!;
export function saveEncrypted<T>(key: string, value: T): void {
const encrypted = CryptoJS.AES.encrypt(
JSON.stringify(value),
SECRET_KEY
).toString();
localStorage.setItem(key, encrypted);
}
export function loadEncrypted<T>(key: string): T | null {
const encrypted = localStorage.getItem(key);
if (!encrypted) return null;
try {
const decrypted = CryptoJS.AES.decrypt(encrypted, SECRET_KEY).toString(
CryptoJS.enc.Utf8
);
return JSON.parse(decrypted);
} catch (error) {
console.error('Decryption failed:', error);
return null;
}
}Quota Management
// utils/quota.ts
export async function checkStorageQuota(): Promise<{
usage: number;
quota: number;
percentUsed: number;
}> {
if ('storage' in navigator && 'estimate' in navigator.storage) {
const { usage = 0, quota = 0 } = await navigator.storage.estimate();
return {
usage,
quota,
percentUsed: (usage / quota) * 100,
};
}
return { usage: 0, quota: 0, percentUsed: 0 };
}
export async function clearOldData(maxAge: number = 30 * 24 * 60 * 60 * 1000) {
const now = Date.now();
// Clear old drafts
const drafts = await getAllDrafts();
for (const draft of drafts) {
if (now - draft.updatedAt > maxAge) {
await deleteDraft(draft.id);
}
}
}Best Practices
- Version your data: Support migrations
- Debounce writes: Don't save on every keystroke
- Handle quota errors: Clear old data
- Encrypt sensitive data: PII, auth tokens
- Test migrations: Verify old data works
- Clear on logout: Remove user data
- Whitelist carefully: Don't persist everything
- Compress large data: Use LZ-string
- Cross-tab sync: Use BroadcastChannel
- Monitor quota: Warn when close to limit
State persistence enhances UX—save user progress and restore seamlessly across sessions!