109 lines
2.9 KiB
TypeScript
109 lines
2.9 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
|
|
export interface ApiState<T> {
|
|
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<T>(fn: () => Promise<T>, deps: unknown[] = []): ApiState<T> {
|
|
const [data, setData] = useState<T | null>(null)
|
|
const [error, setError] = useState<string | null>(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<TArgs extends unknown[], T> {
|
|
run: (...args: TArgs) => Promise<T | null>
|
|
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<TArgs extends unknown[], T>(
|
|
fn: (...args: TArgs) => Promise<T>,
|
|
): AsyncAction<TArgs, T> {
|
|
const [pending, setPending] = useState(false)
|
|
const [error, setError] = useState<string | null>(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<T | null> => {
|
|
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 }
|
|
}
|