FEAT-1: add typed gerbils/lookups API + useApi/useMutation hooks

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 23:52:39 +02:00
parent e04b69ce49
commit f162ef4fed
4 changed files with 159 additions and 1 deletions

View File

@@ -0,0 +1,98 @@
/**
* 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 }
}
/** Wraps a mutation (create/update/delete) with pending + error state. */
export function useMutation<TArgs extends unknown[], TResult>(
mutator: (...args: TArgs) => Promise<TResult>,
): {
run: (...args: TArgs) => Promise<TResult>
pending: boolean
error: string | null
} {
const [pending, setPending] = useState(false)
const [error, setError] = useState<string | null>(null)
const run = useCallback(
async (...args: TArgs) => {
setPending(true)
setError(null)
try {
return await mutator(...args)
} catch (err) {
setError(messageFor(err))
throw err
} finally {
setPending(false)
}
},
[mutator],
)
return { run, pending, error }
}