shahriyar.dev
Back to blog
ReactlocalStoragecustom hooksstate persistenceJavaScriptweb development

Persist React State Like a Pro: Building a Reusable useLocalState Hook

·3 min read

In React, state is ephemeral by default. Refresh the page, and everything you’ve carefully built — a form, a theme toggle, a shopping cart — resets to square one. That works fine for some cases, but often users expect their choices to stick around.

Fortunately, the browser provides localStorage, a simple key-value store that persists data across sessions, even after the tab is closed or the page is reloaded. By combining localStorage with React hooks, we can build a custom solution that handles persistence automatically.

In this post, we’ll create a reusable useLocalState hook. It’s tiny, it’s safe, and it feels exactly like useState. Let’s get started.

Why not just use useState?

React’s built-in useState is perfect for temporary state that resets on reload:

jsx
const [count, setCount] = useState(0)

But after refreshing the browser, count is back to 0. If you want state that survives navigation, reloads, or even closing and reopening the browser, you need something more permanent. That’s where localStorage comes in.

Step 1: Setting up the Hook

Here’s the complete custom hook:

jsx
import { useState, useEffect } from "react"

function useLocalState(key, defaultValue) {
  const [state, setState] = useState(() => {
    try {
      const stored = localStorage.getItem(key)
      return stored ? JSON.parse(stored) : defaultValue
    } catch {
      return defaultValue
    }
  })

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(state))
  }, [key, state])

  return [state, setState]
}

export default useLocalState

How it Works

  • Initialization with lazy state — The useState initializer runs only once. It checks if a value exists in localStorage under the given key. If found, it parses and returns it; otherwise, it falls back to the provided default.
  • Sync with useEffect — Every time the state changes, the effect saves the new value to localStorage as a JSON string. This keeps storage in sync without extra work.
  • Familiar return value — The hook returns [state, setState], exactly like useState. You can use it anywhere without changing your component logic.

Handling Errors Gracefully

The try-catch block inside the lazy initializer ensures that corrupted or unparseable data doesn’t crash your app. If something goes wrong (e.g., localStorage is full or the stored data is invalid), it simply falls back to the default value. This is a small but important safety net.

Using the Hook in a Component

Here’s a simple counter example:

jsx
import React from "react"
import useLocalState from "./useLocalState"

function Counter() {
  const [count, setCount] = useLocalState("count", 0)

  return (
    <div>
      <h1>{count}</h1>
      <button onClick={() => setCount(c => c + 1)}>Increase</button>
      <button onClick={() => setCount(c => c - 1)}>Decrease</button>
    </div>
  )
}

export default Counter

Now, refresh the page as many times as you like — the counter stays exactly where you left it. No more resetting.

Real-World Use Cases

The useLocalState pattern works well whenever you need lightweight persistence without a backend:

  • Dark mode toggle — Save the user’s theme preference so it persists across sessions.
  • Multi-step forms — Keep form inputs filled even if the user accidentally reloads.
  • Shopping cart state — Preserve cart items until the user checks out (useful for single-page apps).
  • Content drafts — Autosave unsent comments, notes, or blog posts.

When to Use Something Else

localStorage is synchronous and limited to about 5–10 MB per origin. For large datasets, sensitive data, or cross-tab synchronization, consider alternatives like IndexedDB, sessionStorage, or a state management library with persistence middleware.

But for most UI preferences and small bits of user data, this hook is simple, effective, and ready to use in minutes.

Comments