Persist React State Like a Pro: Building a Reusable useLocalState Hook
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:
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:
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 useLocalStateHow it Works
- Initialization with lazy state — The
useStateinitializer 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 likeuseState. 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:
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 CounterNow, 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
Related posts
10 Beginner-Friendly JavaScript Problems Solved: Step-by-Step Code Examples
Master JavaScript basics by working through ten beginner-friendly problems with clear, step-by-step code examples—perfect for building confidence with loops, conditionals, arrays, and strings.
Jun 17, 2026 · 6 min read
Setting Up Better Auth with MongoDB in Your Next.js Application
Learn how to seamlessly integrate Better Auth with MongoDB in your Next.js app for a secure, serverless-ready authentication layer—covering everything from setup to route protection in just a few steps.
Jun 17, 2026 · 6 min read
Server Components vs Client Components: when to actually use each
Server Components let you render static content and fetch data server-side with zero JavaScript sent to the client, while Client Components handle interactivity and browser APIs—but the key is to push the client boundary as low as possible, starting with a Server Component by default and only adding `'use client'` when truly necessary. This shift dramatically reduces JavaScript payloads and improves performance without sacrificing rich interactivity.
Jun 17, 2026 · 7 min read