Skip to content

Latest commit

 

History

History
222 lines (155 loc) · 7.98 KB

File metadata and controls

222 lines (155 loc) · 7.98 KB

Hooks

useIntersectionObserver

File: lib/hooks/useIntersectionObserver.ts

Centralized single-element IntersectionObserver hook. Creates exactly one observer, registers the callback on the target, and calls observer.disconnect() automatically when the component unmounts or when a dependency changes.

Cleanup contract: disconnect() is always called in the effect's cleanup function. The callback is kept via a stable ref so it never stales and does not need to be listed as an effect dependency.

import { useIntersectionObserver } from "@/lib/hooks/useIntersectionObserver";

// Returns a ref — attach it to the element you want to watch.
const sentinelRef = useIntersectionObserver<HTMLDivElement>(
  ([entry]) => {
    if (entry.isIntersecting) loadMore();
  },
  { rootMargin: "200px" }
);
return <div ref={sentinelRef} />;

Options extend IntersectionObserverInit plus:

Option Type Default Description
enabled boolean true Set to false to disable the observer without unmounting

An explicit target element or ref can be passed as the third argument when you need to observe an element that is not directly linked to the returned ref.


useScrollSpy

File: lib/hooks/useIntersectionObserver.ts

Scroll-spy hook for multiple section elements. Observes every element whose id is listed in sectionIds, and calls onActivate with the id of whichever section is currently most visible in the viewport. Uses a single IntersectionObserver instance for all sections and calls disconnect() on cleanup.

import { useScrollSpy } from "@/lib/hooks/useIntersectionObserver";

const SECTIONS = ["profile", "security", "preferences"] as const;

function SettingsPage() {
  const [activeId, setActiveId] = useState(SECTIONS[0]);
  useScrollSpy(SECTIONS, setActiveId, {
    rootMargin: "-20% 0px -60% 0px",
    threshold: [0, 0.25, 0.5, 0.75, 1],
  });
  // …
}

Options extend IntersectionObserverInit plus:

Option Type Default Description
enabled boolean true Set to false to disable the observer without unmounting

useInfiniteScrollObserver

File: lib/hooks/useInfiniteScrollObserver.ts

Auto-triggers onLoadMore when a sentinel element scrolls into view. Delegates all observer lifecycle to useIntersectionObserver — there is a single place that owns IntersectionObserver cleanup.

Auto-loading is skipped when IntersectionObserver is not supported or when the user prefers reduced motion; in both cases the caller's manual "load more" trigger remains and must always be rendered.

import { useInfiniteScrollObserver } from "@/lib/hooks/useInfiniteScrollObserver";

function TransactionList({ hasMore, loading, onLoadMore }) {
  const { sentinelRef, isObserverActive } = useInfiniteScrollObserver({
    hasMore,
    loading,
    onLoadMore,
    rootMargin: "200px",
  });

  return (
    <>
      {/* …items… */}
      <div ref={sentinelRef} aria-hidden="true" />
      {!isObserverActive && hasMore && (
        <button onClick={onLoadMore}>Load more</button>
      )}
    </>
  );
}

useScrollRestoration

File: lib/hooks/useScrollRestoration.ts
Component: components/ScrollRestoration.tsx (wired in app/layout.tsx)

Preserves the window scroll position per route when the user navigates with browser Back or Forward, and scrolls to the top on push-style navigations (<Link>, router.push()).

Scenario Result
Push navigation (link click, router.push) Scroll resets to (0, 0)
History navigation (Back / Forward) Restores { x, y } saved for that URL
Tab refresh Not preserved (sessionStorage is per-tab)

Positions are written to sessionStorage under keys shaped rw:scroll:/path?query=1, debounced by 80 ms on scroll and flushed when the route changes.

Routes that manage their own scroll can set window.__rw_skip_scroll_restore = true before navigation completes; the flag is consumed once.

// Already mounted globally — no per-page wiring required.
import ScrollRestoration from "@/components/ScrollRestoration";

// Opt out for a single transition (e.g. hash/filter-only change):
window.__rw_skip_scroll_restore = true;
router.push("/settings#security", { scroll: false });

Unit tests: lib/hooks/useScrollRestoration.test.ts


useEventListener

File: lib/hooks/useEventListener.ts

useEventListener provides a single place to register DOM event listeners from a React component. The listener is automatically removed when the component unmounts or when its event target, event name, or options change.

The event name determines the event type at compile time:

import { useEventListener } from "@/lib/hooks/useEventListener";

function EscapeHandler({ onEscape }: { onEscape: () => void }) {
  useEventListener("keydown", (event) => {
    if (event.key === "Escape") {
      onEscape();
    }
  });

  return null;
}

The default target is window. A Document, HTMLElement, or React ref can be supplied when listening elsewhere:

const buttonRef = useRef<HTMLButtonElement>(null);

useEventListener("click", () => {
  // Handle clicks on the button.
}, buttonRef);

The hook is safe to use in server-rendered components. It also keeps the latest handler without requiring callers to manually register and clean up listeners.

useElementSize

File: lib/hooks/useElementSize.ts

useElementSize reports the size of an element and tracks its changes using ResizeObserver.

import { useElementSize } from "@/lib/hooks/useElementSize";

function ResponsiveWidget() {
  const { ref, width, height } = useElementSize<HTMLDivElement>();

  return (
    <div ref={ref}>
      The element is {width}px wide and {height}px tall.
    </div>
  );
}

You can also pass an existing React ref or an element directly.


useSaveData

File: lib/hooks/useSaveData.ts

Returns true when the browser signals that the user is on a metered or low-bandwidth connection via the Save-Data: on client hint (part of the Network Information API).

When active, chart components swap their rich Recharts visualisations for lightweight static alternatives (tables, bar lists, progress bars) so that heavy library code, SVG paths, and animation timers are not downloaded or executed unnecessarily.

Behaviour:

  • SSR-safe: defaults to false on the server so hydration never mismatches.
  • Reactive: updates immediately when navigator.connection.saveData changes at runtime (e.g. user toggles Data Saver mid-session).
  • Graceful degradation: returns false in environments that do not implement the Network Information API (Safari, Firefox, Node.js).
import { useSaveData } from "@/lib/hooks/useSaveData";

export default function MyChart({ data }) {
  const saveData = useSaveData();

  if (saveData) {
    // Lightweight fallback — no Recharts, no animation
    return <DataTable data={data} />;
  }

  return <FancyAnimatedChart data={data} />;
}

Where it is used:

Component Save-Data fallback
SixMonthTrendsWidget Plain <table> of monthly figures
MoneyDistributionWidget <ul> bar list with colored progress bars
RemittanceTrendChart Ordered list of date / amount pairs
CategoryDonutChart Static progress bars (no interactive donut)
SpendingVsSavingsChart Plain <table> with spending and savings columns

Testing in Chromium: DevTools → Network panel → tick "Save-Data" under custom headers. The chart components will immediately swap to their table / list fallbacks.

Unit tests: tests/unit/hooks/useSaveData.test.ts