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.
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 |
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>
)}
</>
);
}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
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.
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.
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
falseon the server so hydration never mismatches. - Reactive: updates immediately when
navigator.connection.saveDatachanges at runtime (e.g. user toggles Data Saver mid-session). - Graceful degradation: returns
falsein 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