useLocalStorageState
A React hook that syncs state with localStorage
A React hook that persists state to localStorage, automatically syncing across browser tabs and surviving page refreshes. Perfect for storing user preferences, theme settings, authentication tokens, and any data that should persist between sessions.
Source Code
View the full hook implementation in the Hook Source Code section below.
Features
- SSR Safe - Returns
initialValueduring server-side rendering, hydrates on mount - Type Safe - Full TypeScript support with generics for type inference
- Cross-tab Sync - Automatically syncs state when localStorage changes in other tabs
- Error Handling - Graceful fallback with console warnings on storage errors
- Loading State -
isLoadingboolean to handle hydration and prevent flash of incorrect content - Functional Updates - Supports both direct values and updater functions like
useState
Learn More
MDN: Web Storage API
Complete documentation on localStorage and sessionStorage
web.dev: Storage
Guide to choosing the right storage API for your use case
Basic Usage
The simplest way to use useLocalStorageState is just like useState, but with a storage key:
Try it out!
Click the button to change its color, then reload this page - your color preference persists!
"use client";
import { useLocalStorageState } from "@repo/hooks/storage/use-local-storage-state";
const colors = ["blue", "green", "purple", "orange", "pink"] as const;
type ButtonColor = (typeof colors)[number];
const colorClasses: Record<ButtonColor, string> = {
blue: "bg-blue-600 hover:bg-blue-700",
green: "bg-green-600 hover:bg-green-700",
purple: "bg-purple-600 hover:bg-purple-700",
orange: "bg-orange-600 hover:bg-orange-700",
pink: "bg-pink-600 hover:bg-pink-700",
};
/* BASIC USAGE - Button Color Preference */
export const Example1 = () => {
const [color, setColor, isLoading] = useLocalStorageState<ButtonColor>(
"button-color",
"blue",
);
if (isLoading) {
return (
<span className="text-muted-foreground text-sm">Loading...</span>
);
}
const nextColor = () => {
const currentIndex = colors.indexOf(color);
const nextIndex = (currentIndex + 1) % colors.length;
setColor(colors[nextIndex] as (typeof colors)[number]);
};
return (
<div className="flex flex-col items-center gap-4">
<button
className={`rounded-md px-6 py-3 text-sm font-medium text-white transition-colors ${colorClasses[color]}`}
onClick={nextColor}
>
Click to Change Color
</button>
<p className="text-muted-foreground text-sm">
Current: <span className="font-medium capitalize">{color}</span>
</p>
</div>
);
};
With TypeScript Generics
For complex objects, use TypeScript generics to get full type safety. The hook will automatically serialize and deserialize JSON:
Try it out!
Click Login, then reload or open this page in a new tab - you'll stay logged in!
"use client";
import { useLocalStorageState } from "@repo/hooks/storage/use-local-storage-state";
interface User {
id: string;
name: string;
email: string;
}
/* WITH TYPESCRIPT GENERICS - Complex Objects */
export const Example2 = () => {
const [user, setUser, isLoading] = useLocalStorageState<User | null>(
"user",
null,
);
if (isLoading) {
return (
<span className="text-muted-foreground text-sm">Loading...</span>
);
}
const login = () => {
setUser({
id: "123",
name: "John Doe",
email: "john@example.com",
});
};
const logout = () => setUser(null);
return (
<div className="flex items-center gap-4">
{user ? (
<div className="flex flex-col items-center gap-2">
<span className="text-sm">Welcome, {user.name}!</span>
<button
className="bg-destructive text-destructive-foreground rounded-md px-4 py-2 text-sm"
onClick={logout}
>
Logout
</button>
</div>
) : (
<button
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm"
onClick={login}
>
Login
</button>
)}
</div>
);
};
Functional Updates
Just like useState, you can pass a function to setValue that receives the previous value. This is useful when the new state depends on the previous state:
"use client";
import { useLocalStorageState } from "@repo/hooks/storage/use-local-storage-state";
/* FUNCTIONAL UPDATES - Counter */
export const Example3 = () => {
const [count, setCount, isLoading] = useLocalStorageState("counter", 0);
if (isLoading) {
return (
<span className="text-muted-foreground text-sm">Loading...</span>
);
}
return (
<div className="flex items-center gap-4">
<span className="text-sm font-medium">Count: {count}</span>
<button
className="bg-secondary text-secondary-foreground rounded-md px-3 py-1.5 text-sm"
onClick={() => setCount((prev) => prev - 1)}
>
-
</button>
<button
className="bg-secondary text-secondary-foreground rounded-md px-3 py-1.5 text-sm"
onClick={() => setCount((prev) => prev + 1)}
>
+
</button>
<button
className="bg-muted text-muted-foreground rounded-md px-3 py-1.5 text-sm"
onClick={() => setCount(0)}
>
Reset
</button>
</div>
);
};
Array State
The hook handles arrays seamlessly. Perfect for persisting lists, todos, shopping carts, recently viewed items, and more:
Try it out!
Add some todos, then close and reopen the browser - they'll still be there!
"use client";
import { useState } from "react";
import { useLocalStorageState } from "@repo/hooks/storage/use-local-storage-state";
interface TodoItem {
id: number;
text: string;
completed: boolean;
}
/* ARRAY STATE - Todo List */
export const Example4 = () => {
const [todos, setTodos, isLoading] = useLocalStorageState<TodoItem[]>(
"todos",
[],
);
const [input, setInput] = useState("");
const addTodo = () => {
if (!input.trim()) return;
setTodos((prev) => [
...prev,
{ id: Date.now(), text: input, completed: false },
]);
setInput("");
};
const toggleTodo = (id: number) => {
setTodos((prev) =>
prev.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo,
),
);
};
const deleteTodo = (id: number) => {
setTodos((prev) => prev.filter((todo) => todo.id !== id));
};
if (isLoading) {
return (
<span className="text-muted-foreground text-sm">Loading...</span>
);
}
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<div className="flex gap-2">
<input
className="bg-background flex-1 rounded-md border px-3 py-1.5 text-sm"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Add a todo..."
onKeyDown={(e) => e.key === "Enter" && addTodo()}
/>
<button
className="bg-primary text-primary-foreground rounded-md px-3 py-1.5 text-sm"
onClick={addTodo}
>
Add
</button>
</div>
<ul className="flex flex-col gap-1">
{todos.map((todo) => (
<li
key={todo.id}
className="flex items-center justify-between rounded-md border px-3 py-2"
>
<span
className={`cursor-pointer text-sm ${
todo.completed
? "text-muted-foreground line-through"
: ""
}`}
onClick={() => toggleTodo(todo.id)}
>
{todo.text}
</span>
<button
className="text-destructive hover:text-destructive/80"
onClick={() => deleteTodo(todo.id)}
>
×
</button>
</li>
))}
</ul>
</div>
);
};
Handling the Loading State
The isLoading parameter is crucial for SSR applications. During server-side rendering, the hook returns initialValue because localStorage is not available. After the component mounts, it hydrates with the actual stored value.
const [theme, setTheme, isLoading] = useLocalStorageState("theme", "light");
if (isLoading) {
return <Skeleton />; // Prevent flash of wrong content
}
return <div className={theme}>...</div>;Hydration Mismatch
Always handle isLoading to prevent React hydration mismatches. If you
render different content on server vs client, React will throw warnings.
API Reference
function useLocalStorageState<T>(
key: string,
initialValue: T,
): [T, (value: T | ((prev: T) => T)) => void, boolean];Parameters
| Parameter | Type | Description |
|---|---|---|
key | string | The localStorage key to sync with. Should be unique across your app. |
initialValue | T | Default value when no stored value exists or during SSR. |
Returns
A tuple [value, setValue, isLoading]:
| Index | Type | Description |
|---|---|---|
0 | T | The current stored value |
1 | (value: T | (prev: T) => T) => void | Function to update the value (also updates localStorage) |
2 | boolean | true during initial hydration, false after mount completes |
Best Practices
- Use unique keys - Prefix keys with your app name to avoid conflicts:
"myapp-theme","myapp-user" - Handle loading state - Always show a loading indicator or skeleton while
isLoadingis true - Don't store sensitive data - localStorage is not encrypted and accessible via JavaScript
- Consider storage limits - localStorage typically has a 5-10MB limit per origin
Hook Source Code
import { useState, useEffect, useCallback, useRef } from "react";
type SetValue<T> = T | ((prevValue: T) => T);
/**
* Return type for useLocalStorageState hook
*/
export type UseLocalStorageStateReturn<T> = [
T,
(value: SetValue<T>) => void,
boolean,
];
/**
* A React hook that syncs state with localStorage.
* - SSR safe: Returns initialValue during server-side rendering
* - Type-safe: Accepts generic type parameter
* - Handles JSON serialization/deserialization automatically
*
* @param key - The localStorage key to sync with
* @param initialValue - The initial value to use if no stored value exists
* @returns A tuple of [value, setValue, isLoading]
*/
export function useLocalStorageState<T>(
key: string,
initialValue: T,
): UseLocalStorageStateReturn<T> {
const initialValueRef = useRef(initialValue);
const [isLoading, setIsLoading] = useState(true);
const [storedValue, setStoredValue] = useState<T>(initialValue);
// Hydrate from localStorage on mount
useEffect(() => {
try {
const item = localStorage.getItem(key);
if (item !== null) {
setStoredValue(JSON.parse(item) as T);
}
} catch (error) {
console.warn(`Error reading localStorage key "${key}":`, error);
} finally {
setIsLoading(false);
}
}, [key]);
// Update localStorage when value changes
const setValue = useCallback(
(value: SetValue<T>) => {
try {
setStoredValue((prev) => {
const valueToStore =
value instanceof Function ? value(prev) : value;
if (typeof window !== "undefined") {
localStorage.setItem(key, JSON.stringify(valueToStore));
}
return valueToStore;
});
} catch (error) {
console.warn(`Error setting localStorage key "${key}":`, error);
}
},
[key],
);
// Listen for changes from other tabs/windows
useEffect(() => {
const handleStorageChange = (event: StorageEvent) => {
if (event.key === key && event.newValue !== null) {
try {
setStoredValue(JSON.parse(event.newValue) as T);
} catch (error) {
console.warn(
`Error parsing storage event for key "${key}":`,
error,
);
}
} else if (event.key === key && event.newValue === null) {
setStoredValue(initialValueRef.current);
}
};
window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener("storage", handleStorageChange);
}, [key]);
return [storedValue, setValue, isLoading];
}
export default useLocalStorageState;