Centralizing Browser Storage with Type-Safe Custom Hooks

The Problem

Across the ui-web application, localStorage and sessionStorage were accessed directly in 53 different locations. Each call site handled serialization, error handling, and storage quota differently—or not at all.

This scattered approach created multiple categories of bugs: serialization errors when storing complex objects, unhandled quota-exceeded errors causing silent failures, and inconsistent behavior when the same data was accessed from different browser tabs.

More fundamentally, there was no shared pattern. New features inevitably introduced new storage call sites, each potentially reinventing the same problems that had already been solved (or tolerated) elsewhere in the codebase.

Why This Was Hard

Storage seems simple: write to localStorage, read from localStorage. In practice, it's surprisingly fragile.

Browser storage has explicit failure modes—quota exceeded, invalid JSON, cross-origin access—that are easy to ignore in happy-path development. Serialization failures are subtle: they don't crash immediately; they silently store corrupted data that breaks later when it's retrieved.

Multi-tab synchronization adds complexity: if one tab updates storage, other tabs using cached values won't notice. Solving this requires listeners and sync mechanisms that most implementations overlook.

Any solution needed to handle these edge cases transparently, without requiring every call site to reimplement defensive logic.

The Decision

Rather than band-aiding individual call sites, we chose to create reusable custom hooks—useLocalStorage and useSessionStorage—that encapsulated all storage complexity behind a simple, type-safe interface.

The hooks would handle serialization, error management, multi-tab synchronization, and SSR safety automatically. Teams could adopt them incrementally, replacing direct storage access as they touched existing code or built new features.

Execution

The hooks were designed around a few principles:

Type Safety: Generic type parameters allow TypeScript to validate stored data at compile time, catching serialization bugs before runtime.

Auto-Sync Across Tabs: Storage events are listened to globally, so changes in one tab automatically update state in others. Users see consistent data regardless of which tab they're active in.

Transparent Error Handling: Quota-exceeded errors, JSON parse failures, and other storage exceptions are caught and handled gracefully. The hook returns a sensible default rather than throwing.

SSR Safety:Server-side rendering doesn't have access to localStorage. The hooks detect server context and skip storage operations, preventing hydration mismatches.

Optional Persistence: Some data is temporary; some data is critical. The hooks allow configuration of what gets persisted and when.

Implementation involved auditing all 53 call sites, identifying patterns, building the hooks, and then migrating high-risk areas to use them while documenting the pattern for future adoption.

Impact

Storage-related bugs decreased. Engineers no longer needed to reason about serialization or quota handling. New features could adopt the hooks immediately, guaranteeing consistency with existing patterns.

The pattern proved reusable beyond ui-web. Other React projects in the codebase adopted the same hooks, reducing duplication and establishing a shared language for storage handling.

Most importantly, the hooks transformed storage from a source of subtle bugs into a reliable, well-tested infrastructure layer. Teams gained confidence that their data would persist correctly and sync reliably across browser contexts.

Reflection

The most impactful infrastructure work is often invisible—it removes categories of bugs rather than fixing individual bugs.

By creating a single, well-tested solution and making it the easiest path to storage, we eliminated 53 separate implementations and the inconsistencies they brought.

This reinforces a principle: if a pattern appears in many places in your codebase, that's not a sign that it's simple—it's a sign that it's complex enough to warrant a shared solution. The effort to extract and generalize pays dividends as the system scales.