import { useEffect, useRef, useState } from 'react'; export interface AsyncState { data: T | null; error: Error | null; loading: boolean; } const cache = new Map(); /** * Fetch data keyed by `key`; aborts stale requests and memoises successful * responses for the session so back/forward navigation is instant. */ export function useApi(key: string | null, fetcher: (signal: AbortSignal) => Promise): AsyncState { const cached = key ? (cache.get(key) as T | undefined) : undefined; const [state, setState] = useState>({ data: cached ?? null, error: null, loading: key !== null && cached === undefined, }); const fetcherRef = useRef(fetcher); fetcherRef.current = fetcher; useEffect(() => { if (key === null) { setState({ data: null, error: null, loading: false }); return; } const hit = cache.get(key) as T | undefined; if (hit !== undefined) { setState({ data: hit, error: null, loading: false }); return; } const controller = new AbortController(); setState((s) => ({ data: s.data, error: null, loading: true })); fetcherRef .current(controller.signal) .then((data) => { cache.set(key, data); if (!controller.signal.aborted) setState({ data, error: null, loading: false }); }) .catch((error: Error) => { if (!controller.signal.aborted) setState({ data: null, error, loading: false }); }); return () => controller.abort(); }, [key]); return state; } /** Warm the useApi cache for a key (e.g. the next thing a user is likely to open). */ export function prefetch(key: string, fetcher: () => Promise): void { if (cache.has(key)) return; fetcher() .then((data) => cache.set(key, data)) .catch(() => { /* best effort */ }); } /** Resolve a best-effort promise (e.g. an RCSB title) into state. */ export function usePromise(factory: () => Promise | null, deps: unknown[]): T | null { const [value, setValue] = useState(null); useEffect(() => { let alive = true; setValue(null); const p = factory(); p?.then((v) => alive && setValue(v)); return () => { alive = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); return value; } export function useDebounced(value: T, ms: number): T { const [v, setV] = useState(value); useEffect(() => { const t = setTimeout(() => setV(value), ms); return () => clearTimeout(t); }, [value, ms]); return v; }