Front-end Engineering Lab

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

  1. Version your data: Support migrations
  2. Debounce writes: Don't save on every keystroke
  3. Handle quota errors: Clear old data
  4. Encrypt sensitive data: PII, auth tokens
  5. Test migrations: Verify old data works
  6. Clear on logout: Remove user data
  7. Whitelist carefully: Don't persist everything
  8. Compress large data: Use LZ-string
  9. Cross-tab sync: Use BroadcastChannel
  10. Monitor quota: Warn when close to limit

State persistence enhances UX—save user progress and restore seamlessly across sessions!

On this page