Browser Hooks — useLocalStorage, useMediaQuery and useOnline

Browser APIs — localStorage, window.matchMedia, navigator.onLine, the Clipboard API — are not React-aware. They have their own event systems and lifecycles that need to be carefully bridged to React state. Custom hooks are the right place for this bridge: they set up the listeners in useEffect, clean them up on unmount, and expose the current value via useState. Once extracted into a hook, these browser integrations are reusable, tested once, and transparent to the components that use them.

useLocalStorage

// src/hooks/useLocalStorage.js
import { useState, useEffect, useCallback } from "react";

export function useLocalStorage(key, initialValue) {
    const [storedValue, setStoredValue] = useState(() => {
        try {
            const item = window.localStorage.getItem(key);
            return item !== null ? JSON.parse(item) : initialValue;
        } catch {
            return initialValue;
        }
    });

    const setValue = useCallback((value) => {
        try {
            // Allow functional updates: setValue(prev => ...)
            const valueToStore = value instanceof Function
                ? value(storedValue)
                : value;
            setStoredValue(valueToStore);
            window.localStorage.setItem(key, JSON.stringify(valueToStore));
        } catch (err) {
            console.warn(`useLocalStorage: could not write key "${key}"`, err);
        }
    }, [key, storedValue]);

    const removeValue = useCallback(() => {
        try {
            setStoredValue(initialValue);
            window.localStorage.removeItem(key);
        } catch {}
    }, [key, initialValue]);

    return [storedValue, setValue, removeValue];
}

// Usage:
// const [theme, setTheme, clearTheme] = useLocalStorage("theme", "light");
// const [draft, setDraft, clearDraft] = useLocalStorage("post-draft", { title: "", body: "" });
Note: The lazy initialiser in useState(() => { ... }) runs once on mount to read from localStorage. Without the function form, localStorage.getItem(key) would run on every render — the result after the first render is discarded, but the call still happens. For localStorage this is a minor issue, but for expensive computations (parsing large JSON) the lazy form is essential. Always use the function form for any non-trivial initial state computation.
Tip: Add a storage event listener in useLocalStorage to synchronise state across browser tabs. When another tab writes the same key, the storage event fires in all other tabs. Handle it: window.addEventListener("storage", e => { if (e.key === key) setStoredValue(JSON.parse(e.newValue)); }). This makes features like “logout from all tabs” or “sync theme across tabs” effortless — one tab writes the theme, all others update automatically.
Warning: localStorage is synchronous and blocks the main thread. For very large data (thousands of posts, large JSON objects), consider indexedDB or a lightweight wrapper like idb-keyval. Also, localStorage is limited to ~5MB per origin and throws a QuotaExceededError when full — the try/catch in the hook’s setValue prevents this from crashing the application. Always catch localStorage errors; they are more common than developers expect (private browsing, storage full, cross-origin iframes).

useMediaQuery

// src/hooks/useMediaQuery.js
import { useState, useEffect } from "react";

export function useMediaQuery(query) {
    const [matches, setMatches] = useState(
        () => window.matchMedia(query).matches   // initial value
    );

    useEffect(() => {
        const mediaQueryList = window.matchMedia(query);
        setMatches(mediaQueryList.matches);   // sync after hydration

        const handler = (e) => setMatches(e.matches);
        mediaQueryList.addEventListener("change", handler);
        return () => mediaQueryList.removeEventListener("change", handler);
    }, [query]);

    return matches;
}

// Pre-built breakpoint hooks built on useMediaQuery
export const useIsMobile  = () => useMediaQuery("(max-width: 768px)");
export const useIsTablet  = () => useMediaQuery("(max-width: 1024px)");
export const useIsDark    = () => useMediaQuery("(prefers-color-scheme: dark)");
export const useIsReducedMotion = () => useMediaQuery("(prefers-reduced-motion: reduce)");

// Usage:
// const isMobile = useIsMobile();
// const prefersDark = useIsDark();
// if (isMobile) return <MobilePostCard post={post} />;

useOnline

// src/hooks/useOnline.js
import { useState, useEffect } from "react";

export function useOnline() {
    const [isOnline, setIsOnline] = useState(() => navigator.onLine);

    useEffect(() => {
        const handleOnline  = () => setIsOnline(true);
        const handleOffline = () => setIsOnline(false);

        window.addEventListener("online",  handleOnline);
        window.addEventListener("offline", handleOffline);

        return () => {
            window.removeEventListener("online",  handleOnline);
            window.removeEventListener("offline", handleOffline);
        };
    }, []);

    return isOnline;
}

// Usage — show offline banner:
function App() {
    const isOnline = useOnline();
    return (
        <>
            {!isOnline && (
                <div className="bg-yellow-500 text-center py-2 text-sm font-medium">
                    You are offline. Some features may be unavailable.
                </div>
            )}
            <Routes />
        </>
    );
}

Common Mistakes

Mistake 1 — Not cleaning up event listeners (memory leak)

❌ Wrong — addEventListener without removeEventListener:

useEffect(() => {
    window.addEventListener("resize", handleResize);
    // No cleanup — accumulates listeners on every render!
}, []);

✅ Correct — always return a cleanup function.

Mistake 2 — localStorage in SSR / tests without window guard

❌ Wrong — crashes in Node.js (no window):

const item = localStorage.getItem(key);   // ReferenceError: localStorage is not defined

✅ Correct — guard with typeof:

const item = typeof window !== "undefined" ? localStorage.getItem(key) : null;

Quick Reference

Hook Returns Use For
useLocalStorage(key, init) [value, setValue, remove] Persistent state across refreshes
useMediaQuery(query) boolean Responsive JS logic
useIsMobile() boolean Mobile-specific rendering
useIsDark() boolean System dark mode preference
useOnline() boolean Network connectivity status

🧠 Test Yourself

A user opens the blog in two tabs. Tab A saves a draft to useLocalStorage("draft", ""). Does Tab B’s draft state update automatically?