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 |