Efficiently Handling State Persistence

Loading

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

  1. Throttle Writes: Avoid frequent localStorage writes
  2. Compress Data: For large state, consider compression
  3. Partial Persistence: Only persist essential state
  4. Error Handling: Handle quota exceeded errors gracefully
  5. Encryption: For sensitive data, implement encryption
  6. Migration Strategy: Version your state for future updates
  7. 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.

Leave a Reply

Your email address will not be published. Required fields are marked *