Skip to content

Persisting State in localStorage

Keep React state in localStorage with a typed usePersistedState hook, including JSON parsing and safe fallbacks.

Stage 4core2 min read

Problem Statement

Build a counter whose value survives a page reload by saving it in localStorage, then pull that logic into a reusable hook.

Functional Requirements

  1. Counter: Show a number and an Increment button that adds 1.
  2. Persist: Save the count under the count key in localStorage every time it changes.
  3. Restore: On load, read the saved count. If nothing is saved, start at 0.
  4. Reusable hook: Expose usePersistedState(key, initialValue) that returns [value, setValue] like useState.

Technical Constraints

  • Store values as JSON with JSON.stringify and read them back with JSON.parse.
  • Wrap localStorage calls in try/catch so a quota error or bad JSON does not crash the app.
  • Use TypeScript generics so the hook works with any value type.

What is localStorage?

localStorage is a web API that allows you to:

  • Store key-value pairs in the browser
  • Data persists even after the browser is closed
  • Limited to storing strings only (typically 5-10 MB)
  • Data remains until explicitly cleared
// Basic localStorage Usage
localStorage.setItem("name", "John"); // Stores data
localStorage.getItem("name"); // Retrieves data
localStorage.removeItem("name"); // Removes data
localStorage.clear(); // Clears all data

Deep Dive into Both Approaches

Approach 1: Direct Implementation

1. The Storage Utility Functions

// setItem function breakdown
export function setItem(key: string, value: unknown) {
  try {
    // 1. Converts any JavaScript value to a string
    // 2. Stores in localStorage with the given key
    window.localStorage.setItem(key, JSON.stringify(value));
  } catch (error) {
    console.error(error);
  }
}

// getItem function breakdown
export function getItem(key: string) {
  try {
    // 1. Retrieves string from localStorage
    const item = window.localStorage.getItem(key);
    // 2. Converts string back to JavaScript value
    // 3. Returns undefined if item doesn't exist
    return item ? JSON.parse(item) : undefined;
  } catch (error) {
    console.error(error);
  }
}

2. Component Implementation

function App() {
  // Lazy initialization with stored value
  const [count, setCount] = useState(() => {
    const item = getItem("count"); // Check localStorage first
    return (item as number) || 0; // Fallback to 0 if not found
  });

  // Sync with localStorage whenever count changes
  useEffect(() => {
    setItem("count", count);
  }, [count]);
}

Approach 2: Custom Hook Implementation

export function usePersistedState<T>(key: string, initialValue: T) {
  // Generic type T allows hook to work with any data type
  const [value, setValue] = useState(() => {
    const item = getItem(key);
    // Check for undefined, not falsy, so a stored 0, false or "" is kept
    return item !== undefined ? (item as T) : initialValue;
  });

  // Automatically sync with localStorage
  useEffect(() => {
    setItem(key, value);
  }, [key, value]);

  return [value, setValue] as const;
}

Core Concepts to Remember Forever

  1. JSON Handling

    // Why we need JSON methods:
    JSON.stringify(value); // Converts objects/arrays to strings
    JSON.parse(string); // Converts strings back to objects/arrays
  2. Error Handling

    try {
      // localStorage operations can fail due to:
      // - Storage quota exceeded
      // - Private browsing mode
      // - Invalid JSON
    } catch (error) {
      console.error(error);
    }
  3. Lazy Initialization

    // Instead of:
    const [state] = useState(expensiveOperation());
    
    // Use:
    const [state] = useState(() => expensiveOperation());
  4. TypeScript Benefits

    // Generic type ensures type safety
    function usePersistedState<T>(key: string, initialValue: T);
    
    // Usage:
    const [count, setCount] = usePersistedState<number>("count", 0);
    const [user, setUser] = usePersistedState<User>("user", defaultUser);

Key Differences Table

FeatureApproach 1 (Direct)Approach 2 (Hook)
ReusabilityLimited to one use caseHighly reusable
Code OrganizationMore code in componentEncapsulated in hook
Type SafetyBasicAdvanced with generics
ImplementationStraightforwardMore sophisticated

When to Use Each Approach

Use Approach 1 When:

  • Simple one-off storage needs
  • Learning/understanding the concept
  • Quick prototyping

Use Approach 2 When:

  • Multiple components need persistence
  • Working with different data types
  • Building a production application
  • Need reusable storage logic

In this topic

Done with this topic?

How well do you know it?

PreviousTabs