HextorUI
hook

Application hooks

Persisted state, media queries, fuzzy matching and recency ranking — the four the interaction primitives are built on.

npx shadcn@latest add @hextor/app-hooks

Source

"use client";

import { useCallback, useSyncExternalStore } from "react";

const EVENT_PREFIX = "hextor:local-storage:";

/**
 * Caches the last-parsed value per key, keyed off the RAW string still in
 * storage. `useSyncExternalStore` requires `getSnapshot` to return the same
 * reference when nothing changed — re-running `JSON.parse` on every call
 * would hand back a new object identity every time even for an unchanged
 * value, which React reads as "the store is tearing" for any object or
 * array `T` (a primitive `T` like the density toggle's string would happen
 * to survive that; a recent-items record would not). Caching by raw string
 * equality keeps identity stable across calls and only re-parses when the
 * stored bytes actually changed.
 */
const cache = new Map<string, { raw: string | null; value: unknown }>();

function readValue<T>(key: string, fallback: T): T {
  if (typeof window === "undefined") return fallback;
  let raw: string | null;
  try {
    raw = window.localStorage.getItem(key);
  } catch {
    return fallback;
  }
  const cached = cache.get(key);
  if (cached && cached.raw === raw) return cached.value as T;

  let parsed: T = fallback;
  if (raw !== null) {
    try {
      parsed = JSON.parse(raw) as T;
    } catch {
      parsed = fallback;
    }
  }
  cache.set(key, { raw, value: parsed });
  return parsed;
}

/**
 * Persisted state backed by `localStorage`, built on `useSyncExternalStore`
 * — the primitive React ships for exactly this shape of problem: a value
 * that lives outside React and can change from more than one place (this
 * hook's own setter, another tab, another instance of this same hook
 * mounted elsewhere on the page). `getServerSnapshot` returns
 * `initialValue` untouched, so the server and the first client paint always
 * agree; the real stored value (if any) takes over on the very next read,
 * with no extra render-after-mount flash to reason about.
 *
 * The setter dispatches a same-tab custom event after writing. The native
 * `storage` event only reaches OTHER tabs — without this, a density toggle
 * in a header and a copy of the same hook in a settings panel would
 * silently disagree until a reload.
 */
export function useLocalStorageState<T>(
  key: string,
  initialValue: T,
): [T, (value: T | ((prev: T) => T)) => void] {
  const subscribe = useCallback(
    (onStoreChange: () => void) => {
      const eventName = EVENT_PREFIX + key;
      function onCustom() {
        onStoreChange();
      }
      function onStorage(event: StorageEvent) {
        if (event.key === key) onStoreChange();
      }
      window.addEventListener(eventName, onCustom);
      window.addEventListener("storage", onStorage);
      return () => {
        window.removeEventListener(eventName, onCustom);
        window.removeEventListener("storage", onStorage);
      };
    },
    [key],
  );

  const getSnapshot = useCallback(() => readValue(key, initialValue), [key, initialValue]);
  const getServerSnapshot = useCallback(() => initialValue, [initialValue]);

  const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);

  const set = useCallback(
    (next: T | ((prev: T) => T)) => {
      if (typeof window === "undefined") return;
      const prev = readValue(key, initialValue);
      const resolved = typeof next === "function" ? (next as (prev: T) => T)(prev) : next;
      try {
        window.localStorage.setItem(key, JSON.stringify(resolved));
        cache.set(key, { raw: window.localStorage.getItem(key), value: resolved });
        window.dispatchEvent(new CustomEvent(EVENT_PREFIX + key));
      } catch {
        // Storage full, disabled, or blocked by private browsing — the
        // change simply doesn't persist; nothing in this tab crashes for it.
      }
    },
    [key, initialValue],
  );

  return [value, set];
}

Docs

useLocalStorageState and useMediaQuery are built on useSyncExternalStore, not useState plus an effect. The effect version renders the wrong value first and corrects it after mount, which is the flash of the wrong theme, the wrong density, the wrong layout — a bug you only see on a slow device or a cold cache.

useRecentItems ranks by relative position in the recency list rather than elapsed wall-clock time. Reading a clock during render makes the server and the client disagree; position is stable across both and ranks just as well.

useFuzzySearch is a small subsequence scorer rather than a dependency. A command palette does not need a search library.