Wrapping Browser APIs in Typed Custom Hooks: Storage, URL, Observers, and More
Learn to bridge browser-native APIs — localStorage, sessionStorage, IndexedDB, cookies, the URL/URLSearchParams and History APIs, the Clipboard API, matchMedia, IntersectionObserver, ResizeObserver, and MutationObserver — into React by wrapping them in typed custom hooks that manage subscriptions and cleanup correctly.
Browser APIs exist independently of React and know nothing about component render cycles, so using them safely inside a component means synchronizing them with React state: reading their current value into useState, subscribing to their change events in useEffect, and always cleaning up that subscription — the same pattern this lesson applies across a dozen different browser APIs, using one full worked example (useLocalStorage) as the central case study.
Storage APIs: localStorage, sessionStorage, and IndexedDB
localStorage and sessionStorage both store string key-value pairs synchronously in the browser, with a typical quota around 5-10MB depending on the browser. localStorage persists until explicitly cleared (surviving tab closes and browser restarts), while sessionStorage is scoped to a single tab and cleared when that tab closes — making localStorage the right choice for a saved user preference, and sessionStorage right for something like an in-progress multi-step form that shouldn't survive opening a new tab.
Both only store strings, so any non-string value needs JSON.stringify before writing and JSON.parse after reading, and both throw if the write exceeds the quota or if storage access is blocked (private browsing modes in some browsers restrict or disable it) — a robust wrapper needs to catch those errors rather than let them crash the component.
IndexedDB is a different tier entirely: an asynchronous, transactional, object-store-based database built into the browser, suited for structured data at a much larger scale than localStorage's quota allows — offline-first apps caching large datasets, or storing files/blobs client-side. Its raw API is callback-based and verbose, so in practice most React code reaches for a wrapper library (like idb) rather than using the native IndexedDB API directly, though the underlying concept — object stores, indexes, transactions — is worth understanding even when a library handles the plumbing.
Cookies, URL, URLSearchParams, and the History API
Cookies (document.cookie) are small pieces of data sent automatically with every HTTP request to their domain, which makes them the right tool specifically for data the server needs to see (session tokens, for example) — unlike localStorage, which never leaves the browser. document.cookie's string-based get/set interface is notoriously awkward (reading it back requires manually parsing a semicolon-delimited string), so cookie access is almost always wrapped in a small parse/serialize helper or a library.
The URL and URLSearchParams constructors give you a structured way to read and build URLs instead of manipulating strings by hand: new URL(window.location.href) parses the current address into its parts (pathname, search, hash, origin), and new URLSearchParams(window.location.search) parses the query string into a get/set/has/delete interface — new URLSearchParams('?page=2&sort=price').get('sort') returns 'price' without any manual string splitting.
The History API (history.pushState / history.replaceState, plus the popstate event) is what lets an app change the URL shown in the address bar without a full page reload — the mechanism client-side routers like React Router are built on. pushState adds a new entry to the browser's history stack (so the back button returns to the previous URL), while replaceState swaps the current entry in place; popstate fires when the user navigates with the browser's back/forward buttons, which a router (or a hook wrapping this directly) needs to listen for to re-render the right view.
Clipboard, matchMedia, and geolocation/notifications awareness
The async Clipboard API (navigator.clipboard.writeText / readText) replaced the older document.execCommand('copy') approach and returns a Promise, which means a 'copy to clipboard' button in React should await that promise and reflect success or failure in state (e.g. briefly showing 'Copied!') rather than assuming the write always succeeds — it can be rejected if the page lacks focus or clipboard permission.
window.matchMedia(query) evaluates a CSS media query and returns a MediaQueryList with a live .matches boolean and a 'change' event that fires whenever that boolean flips — this is the browser API that responsive React components use to react to breakpoint changes in JavaScript, not just in CSS, and it's the central worked example for this lesson (see the full useMediaQuery hook below).
Geolocation (navigator.geolocation.getCurrentPosition / watchPosition) and the Notifications API (Notification.requestPermission, new Notification(...)) both follow a common browser-API shape worth being aware of even without building a full example of each: both require explicit user permission (a browser-native prompt), both are unavailable or behave differently across browsers and contexts (geolocation requires a secure HTTPS context; notifications require the permission to have been granted, not just requested), and both should be treated as optional/progressively-enhanced features that the app degrades gracefully without, since a user can always deny the permission prompt.
The Observer trio: IntersectionObserver, ResizeObserver, MutationObserver
IntersectionObserver efficiently detects when an element enters or leaves the viewport (or another ancestor's bounds) without the performance cost of scroll-event listeners doing manual getBoundingClientRect() calculations on every scroll tick — it's the standard mechanism behind lazy-loaded images, infinite scroll (loading more content when a sentinel element at the bottom becomes visible), and scroll-triggered animations.
ResizeObserver reports when an observed element's size changes, for any reason — a window resize, a sibling element growing, a font finishing its load and reflowing the layout, or a flex/grid container redistributing space. This is the correct tool for a responsive component that needs to react to its own rendered size (as opposed to matchMedia, which only knows about viewport width) — for example, a chart that needs to redraw at a new pixel size, or the container-query-style component patterns from the Responsive React UI module implemented in JS instead of CSS.
MutationObserver watches a DOM subtree for changes — added/removed child nodes, attribute changes, text content changes — and is the least commonly needed of the three in ordinary React code, since React itself owns the DOM tree and components should generally react to state changes rather than DOM mutations. It's mainly relevant when integrating with third-party, non-React code that mutates the DOM outside React's control (a legacy widget, a browser extension's injected content) and a component needs to notice and respond to those external changes.
useLocalStorage: a full worked custom hook
readValue is guarded with typeof window !== 'undefined' so the hook doesn't crash during server-side rendering. Both getItem and setItem are wrapped in try/catch because localStorage can throw — quota exceeded, or storage disabled entirely in some private-browsing modes. The 'storage' event only fires in OTHER tabs/windows when localStorage changes there, which is what lets this hook keep multiple open tabs in sync automatically.
import { useState, useCallback, useEffect } from "react";
function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
const readValue = useCallback((): T => {
if (typeof window === "undefined") return initialValue;
try {
const item = window.localStorage.getItem(key);
return item !== null ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
}, [key, initialValue]);
const [storedValue, setStoredValue] = useState<T>(readValue);
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
setStoredValue((prev) => {
const next = value instanceof Function ? value(prev) : value;
try {
window.localStorage.setItem(key, JSON.stringify(next));
} catch {
// quota exceeded or storage disabled (e.g. private browsing) — fail silently
}
return next;
});
},
[key]
);
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === key) setStoredValue(readValue());
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage",
…Code Examples
The component reads and writes theme through the hook exactly like useState — the persistence to localStorage, JSON serialization, and cross-tab sync are entirely hidden behind that same familiar [value, setValue] interface. Expected result: The chosen theme survives a page reload, and if the user has the app open in two tabs, toggling the theme in one tab updates the other tab too via the storage event listener.
Three separate browser APIs (matchMedia, IntersectionObserver, and URLSearchParams + History API) are each wrapped the same way: read the current value, subscribe in an effect, clean up the subscription. useIsVisible is the standard building block behind lazy-loaded images and infinite scroll; useSearchParam demonstrates driving app state from the URL's query string using pushState so the browser's back button and shareable links both work correctly.
const observer = new IntersectionObserver(([entry]) => setIsVisible(entry.isIntersecting));
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "light");
return (
<button onClick={() => setTheme((prev) => (prev === "light" ? "dark" : "light"))}>
Switch to {theme === "light" ? "dark" : "light"} mode
</button>
);
}function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() =>
typeof window !== "undefined" ? window.matchMedia(query).matches : false
);
useEffect(() => {
const mql = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener("change", handler);
setMatches(mql.matches);
return () => mql.removeEventListener("change", handler);
}, [query]);
return matches;
}
function useIsVisible<T extends HTMLElement>(): [React.RefObject<T | null>, boolean] {
const ref = useRef<T | null>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const node = ref.current;
if (!node) return;
const observer = new IntersectionObserver(([entry]) => setIsVisible(entry.isIntersecting));
observer.observe(node);
return () => observer.disconnect();
}, []);
return [ref, isVisible];
}
function useSearchParam(name: string): [string | null, (value: string) => void] {
const [value, setValueState] = useState<string | null>(() =>
new URLSearchParams(window.location.search).get(name)
);
const setValue = useCallback(
(next: string)
…The common shape of a browser-API-wrapping hook
localStorage, matchMedia, IntersectionObserver, ResizeObserver, and MutationObserver are all wrapped with the same read-subscribe-cleanup shape, which is why learning it once via useLocalStorage transfers directly to every other browser API hook.
useState(() => read initial value, guarded for SSR)
|
v
useEffect(() => {
subscribe to the browser API's change event
update state when it fires
return () => unsubscribe <- cleanup, always
}, [dependencies])
|
v
return state to the component, looking just like any other React valueImportant Rules
Guard any direct access to window, document, navigator, or localStorage with a typeof window !== 'undefined' check (or an SSR-safe initializer) since these don't exist during server-side rendering.
Always return a cleanup function from useEffect that removes the event listener or disconnects the observer — a forgotten cleanup leaks the subscription on every unmount/remount.
Wrap localStorage/sessionStorage reads and writes in try/catch — both can throw (quota exceeded, or storage disabled in some private-browsing modes).
Use IntersectionObserver instead of scroll-event + getBoundingClientRect() for visibility detection — it's far cheaper and doesn't run on every scroll tick.
Treat geolocation and notification permissions as optional, user-declinable features the app must degrade gracefully without — never assume the permission prompt will be accepted.
Use pushState for a navigation the user should be able to undo with the back button, and replaceState for state changes that shouldn't add a new history entry.
Where this shows up
Persisted user preferences (theme, sidebar collapsed state) via useLocalStorage, infinite-scroll feeds and lazy-loaded images via useIsVisible/IntersectionObserver, responsive components that react to viewport or container size via useMediaQuery/ResizeObserver, shareable filtered/sorted views driven by useSearchParam and the History API, and copy-to-clipboard buttons via the async Clipboard API are all everyday production patterns built from exactly this wrap-the-browser-API-in-a-hook approach.
const [value, setValue] = useState(localStorage.getItem("theme"));
const [value, setValue] = useState<string | null>(() => typeof window !== "undefined" ? window.localStorage.getItem("theme") : null );
Calling localStorage.getItem directly in the initial useState argument runs on every render's function call site during SSR where window/localStorage don't exist, crashing the render. It should be wrapped in a lazy initializer function guarded by a typeof window check.
useEffect(() => { const observer = new IntersectionObserver(callback); observer.observe(ref.current); }, []);
useEffect(() => { const node = ref.current; if (!node) return; const observer = new IntersectionObserver(callback); observer.observe(node); return () => observer.disconnect(); }, []);
Without returning a cleanup function, the observer is never disconnected on unmount, leaking the subscription; repeated mounts (e.g. via React Strict Mode's double-invoke in development, or a list re-rendering) accumulate observers that keep firing against stale DOM nodes.
[multiple-choice] What is the key structural difference between localStorage and sessionStorage? A. localStorage only stores numbers; sessionStorage only stores strings B. localStorage persists until explicitly cleared and is shared across tabs; sessionStorage is scoped to a single tab and cleared when it closes C. sessionStorage is asynchronous and localStorage is synchronous D. There is no functional difference, only a naming convention
B. localStorage persists until explicitly cleared and is shared across tabs; sessionStorage is scoped to a single tab and cleared when it closes — localStorage persists indefinitely (until cleared) and is shared across all tabs/windows for the same origin, while sessionStorage is scoped to a single tab and is cleared when that tab closes.
[multiple-choice] Why is IntersectionObserver generally preferred over a scroll event listener with manual getBoundingClientRect() calls for detecting element visibility? A. IntersectionObserver works in more browsers than scroll events B. getBoundingClientRect() cannot be called inside React components C. IntersectionObserver is asynchronous and far cheaper, since it doesn't recompute layout on every scroll tick D. Scroll events don't exist in modern browsers
C. IntersectionObserver is asynchronous and far cheaper, since it doesn't recompute layout on every scroll tick — IntersectionObserver's callback fires asynchronously only when intersection actually changes, avoiding the performance cost of running getBoundingClientRect() (which forces layout) on every single scroll event, which can fire dozens of times per second.
[true-false] It's safe to call localStorage.getItem() directly as the argument passed to useState() without any guard, in a component that might be server-rendered. A. True B. False
B. False — localStorage doesn't exist in a server-side rendering environment (there's no window), so calling it directly crashes the render there. It should be wrapped in a lazy useState initializer guarded by a typeof window !== 'undefined' check.
Adapt the useLocalStorage pattern to build a useSessionStorage<T>(key, initialValue) hook with the same [value, setValue] signature, but backed by sessionStorage instead — and explain in a comment why it does NOT need the cross-tab 'storage' event listener that useLocalStorage has.
function useSessionStorage<T>( key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] { const [storedValue, setStoredValue] = useState<T>(() => { if (typeof window === "undefined") return initialValue; try { const item = window.sessionStorage.getItem(key); return item !== null ? (JSON.parse(item) as T): initialValue; } catch { return initialValue; } }); const setValue = useCallback( (value: T | ((prev: T) => T)) => { setStoredValue((prev) => { const next = value instanceof Function ? value(prev): value; try { window.sessionStorage.setItem(key, JSON.stringify(next)); } catch { // quota exceeded or storage disabled — fail silently } return next; }); }, [key]); // No 'storage' event listener needed: sessionStorage is scoped to this single tab, // so no other tab/window shares this storage area to fire a cross-tab change event from. return [storedValue, setValue]; } sessionStorage is scoped to a single tab, so there's no other tab to receive a storage event from for the same key.