Fiber UI LogoFiberUI

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 initialValue during 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 - isLoading boolean to handle hydration and prevent flash of incorrect content
  • Functional Updates - Supports both direct values and updater functions like useState

Learn More


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!

Loading...
"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!

Loading...
"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:

Loading...
"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!

Loading...
"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

ParameterTypeDescription
keystringThe localStorage key to sync with. Should be unique across your app.
initialValueTDefault value when no stored value exists or during SSR.

Returns

A tuple [value, setValue, isLoading]:

IndexTypeDescription
0TThe current stored value
1(value: T | (prev: T) => T) => voidFunction to update the value (also updates localStorage)
2booleantrue during initial hydration, false after mount completes

Best Practices

  1. Use unique keys - Prefix keys with your app name to avoid conflicts: "myapp-theme", "myapp-user"
  2. Handle loading state - Always show a loading indicator or skeleton while isLoading is true
  3. Don't store sensitive data - localStorage is not encrypted and accessible via JavaScript
  4. 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;