import { useCallback, useEffect, useRef, useState } from 'react' export interface ApiState { data: T | null error: string | null loading: boolean refetch: () => void } function message(err: unknown): string { if (err instanceof Error) return err.message if (typeof err === 'string') return err return 'request failed' } /** * Runs `fn` on mount and whenever `deps` change, aborting the previous request * so a fast filter change cannot let a slow earlier response win a race. */ export function useApi(fn: () => Promise, deps: unknown[] = []): ApiState { const [data, setData] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(true) const [nonce, setNonce] = useState(0) const fnRef = useRef(fn) fnRef.current = fn useEffect(() => { // The fetcher takes no AbortSignal, so cancellation is a guard: a response // that arrives after an unmount or a newer request is dropped on the floor // instead of overwriting fresher state. let live = true setLoading(true) fnRef .current() .then((value) => { if (!live) return setData(value) setError(null) }) .catch((err: unknown) => { if (!live) return if (err instanceof DOMException && err.name === 'AbortError') return setError(message(err)) }) .finally(() => { if (!live) return setLoading(false) }) return () => { live = false } // eslint-disable-next-line react-hooks/exhaustive-deps }, [...deps, nonce]) const refetch = useCallback(() => setNonce((n) => n + 1), []) return { data, error, loading, refetch } } export interface AsyncAction { run: (...args: TArgs) => Promise pending: boolean error: string | null clearError: () => void } /** * A fire-and-forget mutation: `run` never rejects, it resolves to `null` on * failure and puts the message in `error` so the caller can just toast it. */ export function useAsyncAction( fn: (...args: TArgs) => Promise, ): AsyncAction { const [pending, setPending] = useState(false) const [error, setError] = useState(null) const mounted = useRef(true) const fnRef = useRef(fn) fnRef.current = fn useEffect(() => { mounted.current = true return () => { mounted.current = false } }, []) const run = useCallback(async (...args: TArgs): Promise => { setPending(true) setError(null) try { const result = await fnRef.current(...args) return result } catch (err) { if (mounted.current) setError(message(err)) return null } finally { if (mounted.current) setPending(false) } }, []) const clearError = useCallback(() => setError(null), []) return { run, pending, error, clearError } }