/** * 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 { 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 { key: string | null data: T | null error: string | null } export function useApi(loader: () => Promise, deps: unknown[]): AsyncState { 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>({ 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 = | { 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( mutator: (...args: TArgs) => Promise, ): { run: (...args: TArgs) => Promise> pending: boolean error: string | null } { const [pending, setPending] = useState(false) const [error, setError] = useState(null) const run = useCallback( async (...args: TArgs): Promise> => { 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 } }