115 lines
3.6 KiB
TypeScript
115 lines
3.6 KiB
TypeScript
/**
|
|
* Minimal async-data hooks (no react-query dependency).
|
|
*
|
|
* `useApi` runs an async loader and tracks loading/error/data, re-running when
|
|
* `deps` change. `reload()` re-fetches on demand (e.g. after a mutation).
|
|
*
|
|
* Design notes (to satisfy the strict react-hooks rules):
|
|
* - `loading` is DERIVED (resolved-key !== current-request-key), never set
|
|
* synchronously inside the effect.
|
|
* - the loader is read through a ref so the effect can key purely on the
|
|
* request signature without re-running on every render.
|
|
*/
|
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import { ApiError } from '../api/client'
|
|
import { de } from '../strings/de'
|
|
|
|
export interface AsyncState<T> {
|
|
data: T | null
|
|
loading: boolean
|
|
error: string | null
|
|
reload: () => void
|
|
}
|
|
|
|
function messageFor(err: unknown): string {
|
|
if (err instanceof ApiError) return err.message
|
|
if (err instanceof Error && err.message) return err.message
|
|
return de.api.errors.unknown
|
|
}
|
|
|
|
interface Resolved<T> {
|
|
key: string | null
|
|
data: T | null
|
|
error: string | null
|
|
}
|
|
|
|
export function useApi<T>(loader: () => Promise<T>, deps: unknown[]): AsyncState<T> {
|
|
const loaderRef = useRef(loader)
|
|
// Keep the loader ref current (declared before the request effect so it is
|
|
// synced first within a commit). Writing refs in render is disallowed.
|
|
useEffect(() => {
|
|
loaderRef.current = loader
|
|
})
|
|
|
|
const [nonce, setNonce] = useState(0)
|
|
const requestKey = `${nonce}:${JSON.stringify(deps)}`
|
|
|
|
const [resolved, setResolved] = useState<Resolved<T>>({ key: null, data: null, error: null })
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
loaderRef
|
|
.current()
|
|
.then((result) => {
|
|
if (!cancelled) setResolved({ key: requestKey, data: result, error: null })
|
|
})
|
|
.catch((err) => {
|
|
if (!cancelled) setResolved({ key: requestKey, data: null, error: messageFor(err) })
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [requestKey])
|
|
|
|
const reload = useCallback(() => setNonce((n) => n + 1), [])
|
|
const loading = resolved.key !== requestKey
|
|
|
|
return { data: resolved.data, loading, error: loading ? null : resolved.error, reload }
|
|
}
|
|
|
|
/**
|
|
* Outcome of a mutation. Discriminated on `ok` so callers MUST branch before
|
|
* touching `value` — this makes error handling compile-time-visible and means
|
|
* `run()` NEVER throws (no more unhandled rejections from async event handlers).
|
|
* Works for void mutations too (delete): success is `{ ok: true, value: undefined }`.
|
|
*/
|
|
export type MutationOutcome<T> =
|
|
| { ok: true; value: T }
|
|
| { ok: false; error: string; cause: unknown }
|
|
|
|
/**
|
|
* Wraps a mutation (create/update/delete) with pending + error state.
|
|
* `run()` resolves to a MutationOutcome and never rejects; `error` also drives
|
|
* inline UI as before. Call sites: `const r = await m.run(x); if (r.ok) …`.
|
|
*/
|
|
export function useMutation<TArgs extends unknown[], TResult>(
|
|
mutator: (...args: TArgs) => Promise<TResult>,
|
|
): {
|
|
run: (...args: TArgs) => Promise<MutationOutcome<TResult>>
|
|
pending: boolean
|
|
error: string | null
|
|
} {
|
|
const [pending, setPending] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const run = useCallback(
|
|
async (...args: TArgs): Promise<MutationOutcome<TResult>> => {
|
|
setPending(true)
|
|
setError(null)
|
|
try {
|
|
const value = await mutator(...args)
|
|
return { ok: true, value }
|
|
} catch (err) {
|
|
const message = messageFor(err)
|
|
setError(message)
|
|
return { ok: false, error: message, cause: err }
|
|
} finally {
|
|
setPending(false)
|
|
}
|
|
},
|
|
[mutator],
|
|
)
|
|
|
|
return { run, pending, error }
|
|
}
|