src/lib/use-remote-load.ts
Linked repository file from this build.
/* oxlint-disable react/set-state-in-effect -- This hook is the one place that syncs remote loads into state. */
import { useEffect, useRef, useState, type DependencyList } from "react";
/**
* Run `load` whenever `deps` change, and abort the run before it when they change again.
* `load` stores its own data. The hook tracks loading, the error message and the time of
* the last successful run. `delayMs` debounces typing; `enabled: false` skips runs.
* `reset` runs at the start of every run, even a skipped one, to clear stale results.
* `skipFirstRun` keeps server-rendered data on mount instead of fetching it again.
*/
export function useRemoteLoad(
load: (signal: AbortSignal) => Promise<void>,
deps: DependencyList,
options: {
enabled?: boolean;
delayMs?: number;
initialRefreshedAt?: string | null;
reset?: () => void;
skipFirstRun?: boolean;
/** Report loading before the first run, for pages that start without data. */
initialLoading?: boolean;
} = {},
) {
const { enabled = true, delayMs = 0, initialRefreshedAt = null } = options;
const [loading, setLoading] = useState(Boolean(options.initialLoading));
const [error, setError] = useState("");
const [refreshedAt, setRefreshedAt] = useState(initialRefreshedAt);
const latest = useRef({ load, reset: options.reset });
const skip = useRef(Boolean(options.skipFirstRun));
useEffect(() => {
latest.current = { load, reset: options.reset };
});
useEffect(() => {
if (enabled && skip.current) {
skip.current = false;
return;
}
latest.current.reset?.();
setError("");
if (!enabled) return;
const controller = new AbortController();
setLoading(true);
const run = async () => {
try {
await latest.current.load(controller.signal);
if (!controller.signal.aborted) setRefreshedAt(new Date().toISOString());
} catch (cause) {
if (!controller.signal.aborted)
setError(cause instanceof Error ? cause.message : "Unable to load. Please retry.");
} finally {
if (!controller.signal.aborted) setLoading(false);
}
};
const timer = delayMs ? setTimeout(run, delayMs) : undefined;
if (!delayMs) void run();
return () => {
clearTimeout(timer);
controller.abort();
};
// oxlint-disable-next-line react-hooks/exhaustive-deps -- The caller's deps decide when to reload; `load` is read from the ref.
}, [enabled, delayMs, ...deps]);
return { loading, error, setError, refreshedAt };
}