State persistence is crucial for maintaining user experience across sessions while optimizing performance. Here’s a comprehensive guide to implementing efficient state persistence in React applications.
1. Local Storage Strategies
Basic Local Storage Hook
import { useState, useEffect } from 'react';
function usePersistedState(key, initialValue) {
const [state, setState] = useState(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem(key);
return saved !== null ? JSON.parse(saved) : initialValue;
}
return initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(state));
}, [key, state]);
return [state, setState];
}
// Usage
const [theme, setTheme] = usePersistedState('app-theme', 'light');
Optimized Version with Debouncing
function useDebouncedPersistedState(key, initialValue, delay = 500) {
const [state, setState] = useState(() => {
// Same initializer as above
});
useEffect(() => {
const timer = setTimeout(() => {
localStorage.setItem(key, JSON.stringify(state));
}, delay);
return () => clearTimeout(timer);
}, [key, state, delay]);
return [state, setState];
}
2. IndexedDB for Larger Data
Using idb-keyval (Lightweight Wrapper)
import { get, set } from 'idb-keyval';
async function useIndexedDBState(key, initialValue) {
const [state, setState] = useState(initialValue);
// Initialize from IndexedDB
useEffect(() => {
(async () => {
const saved = await get(key);
if (saved !== undefined) setState(saved);
})();
}, [key]);
// Persist to IndexedDB
useEffect(() => {
set(key, state);
}, [key, state]);
return [state, setState];
}
Transactional IndexedDB with Compression
async function saveLargeData(key, data) {
const compressed = await compressData(data); // Use pako or similar
const db = await openDB('my-db', 1, {
upgrade(db) {
db.createObjectStore('persisted-state');
},
});
await db.put('persisted-state', compressed, key);
}
async function loadLargeData(key) {
const db = await openDB('my-db', 1);
const compressed = await db.get('persisted-state', key);
return compressed ? decompressData(compressed) : null;
}
3. State Management Library Integrations
Zustand Persistence Middleware
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
const useStore = create(
persist(
(set) => ({
user: null,
setUser: (user) => set({ user }),
}),
{
name: 'user-storage',
storage: createJSONStorage(() => localStorage),
// or for IndexedDB:
// storage: createJSONStorage(() => ({
// getItem: async (name) => await get(name),
// setItem: async (name, value) => await set(name, value),
// })),
}
)
);
Jotai with Persistence
import { atom } from 'jotai';
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
// Simple version
const themeAtom = atomWithStorage('theme', 'light');
// Custom storage implementation
const customStorage = {
getItem: async (key) => {
const stored = await AsyncStorage.getItem(key);
return stored ? JSON.parse(stored) : null;
},
setItem: async (key, value) => {
await AsyncStorage.setItem(key, JSON.stringify(value));
},
removeItem: async (key) => {
await AsyncStorage.removeItem(key);
},
};
const userAtom = atomWithStorage('user', null, customStorage);
4. Advanced Patterns
Differential Persistence
function useDifferentialPersist(key, state, importantKeys) {
useEffect(() => {
const toPersist = {};
importantKeys.forEach(k => {
toPersist[k] = state[k];
});
localStorage.setItem(key, JSON.stringify(toPersist));
}, [key, state, importantKeys]);
}
// Usage in component
useDifferentialPersist('user-session', userState, ['preferences', 'recentViews']);
Time-to-Live (TTL) for State
function useTTLPersistedState(key, initialValue, ttl) {
const [state, setState] = useState(() => {
const item = localStorage.getItem(key);
if (item) {
const { value, timestamp } = JSON.parse(item);
if (Date.now() - timestamp < ttl) {
return value;
}
}
return initialValue;
});
useEffect(() => {
const item = {
value: state,
timestamp: Date.now()
};
localStorage.setItem(key, JSON.stringify(item));
}, [key, state]);
return [state, setState];
}
// Usage (persist for 1 hour)
const [session, setSession] = useTTLPersistedState('user-session', null, 3600000);
5. Server-Side Rendering (SSR) Considerations
Next.js with Zustand
// lib/store.js
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const useStore = create(
persist(
(set) => ({
/* state */
}),
{
name: 'store',
getStorage: () => ({
getItem: (name) => {
if (typeof window !== 'undefined') {
return localStorage.getItem(name);
}
return null;
},
setItem: (name, value) => {
if (typeof window !== 'undefined') {
localStorage.setItem(name, value);
}
},
removeItem: (name) => {
if (typeof window !== 'undefined') {
localStorage.removeItem(name);
}
},
}),
}
)
);
// pages/_app.js
export default function App({ Component, pageProps }) {
// Hydrate store from server if needed
return <Component {...pageProps} />;
}
Universal Persistence Hook
function useUniversalPersist(key, initialValue) {
const [state, setState] = useState(initialValue);
useEffect(() => {
const loadState = async () => {
let saved;
if (typeof window !== 'undefined') {
saved = localStorage.getItem(key);
} else if (typeof globalThis.__SERVER_STATE__ !== 'undefined') {
saved = globalThis.__SERVER_STATE__[key];
}
setState(saved !== null ? JSON.parse(saved) : initialValue);
};
loadState();
}, [key, initialValue]);
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem(key, JSON.stringify(state));
}
}, [key, state]);
return [state, setState];
}
6. Performance Optimization Techniques
Batching Writes
function useBatchedPersist(key, state, delay = 1000) {
const batchRef = useRef([]);
const timerRef = useRef(null);
useEffect(() => {
batchRef.current.push(state);
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
localStorage.setItem(key, JSON.stringify(batchRef.current));
batchRef.current = [];
}, delay);
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, [key, state, delay]);
}
Selective Serialization
function useSelectivePersist(key, state, serializer) {
useEffect(() => {
const serialized = serializer(state);
localStorage.setItem(key, JSON.stringify(serialized));
}, [key, state, serializer]);
}
// Usage
useSelectivePersist('complex-state', state, (s) => ({
important: s.important,
// Skip large or non-serializable data
}));
Best Practices
- Throttle Writes: Avoid frequent localStorage writes
- Compress Data: For large state, consider compression
- Partial Persistence: Only persist essential state
- Error Handling: Handle quota exceeded errors gracefully
- Encryption: For sensitive data, implement encryption
- Migration Strategy: Version your state for future updates
- Cleanup: Remove obsolete state periodically
Error Handling and Edge Cases
Quota Management
function useSafeLocalStorage(key, initialValue) {
const [state, setState] = useState(initialValue);
const safeSetItem = useCallback((value) => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
if (e.name === 'QuotaExceededError') {
// Implement your quota management strategy:
// 1. Clear old data
// 2. Use LRU eviction
// 3. Fallback to sessionStorage
console.warn('LocalStorage quota exceeded');
}
}
}, [key]);
useEffect(() => {
const saved = localStorage.getItem(key);
if (saved !== null) setState(JSON.parse(saved));
}, [key]);
useEffect(() => {
safeSetItem(state);
}, [key, state, safeSetItem]);
return [state, setState];
}
State Versioning
const STATE_VERSION = '1.0';
function useVersionedPersist(key, initialValue) {
const [state, setState] = useState(() => {
const saved = localStorage.getItem(key);
if (saved) {
const { version, data } = JSON.parse(saved);
if (version === STATE_VERSION) {
return data;
}
// Else: migrate old state or discard
}
return initialValue;
});
useEffect(() => {
const versionedState = {
version: STATE_VERSION,
data: state
};
localStorage.setItem(key, JSON.stringify(versionedState));
}, [key, state]);
return [state, setState];
}
By implementing these patterns, you can create React applications that maintain state efficiently across sessions while optimizing for performance and user experience. Choose the right persistence strategy based on your application’s specific needs and data characteristics.