/** * useRecord — one record from one endpoint, on the useHistory * pattern. * * Named for what it returns, and deliberately not useResource: * src/lib/useResource.js is a different hook — it hands back the * whole response body and takes an options object — and a .ts file * of the same name would sit one extension away from it. An import * whose target goes missing then resolves to the other file without * a word, and every detail page renders the wrapper object instead * of the record. That is exactly how this file came to exist. * * Every detail route in content.js answers the same shape: 200 with * a single top-level key, or 404 with `{ error }`. So the hook takes * the path and the key, and the four callers in useContent.ts are * one line each rather than four copies of this file. * * `key` is a string rather than a selector function on purpose. A * selector passed inline would be a new identity every render, and * putting it in the effect's deps would refetch forever; leaving it * out would silently use a stale closure. A string has neither * problem. * * Carried over from useHistory, and worth restating: * * · `reload` invalidates before it refetches. Without that, the * retry button inside the 60s TTL hands back the same settled * promise and looks like it did nothing. A *failed* request is * already evicted by api.js, so this matters for the refresh * case rather than the error case. * * · no AbortController. `get` shares one promise between callers, * so aborting on unmount would cancel someone else's request. * The `live` flag drops the result instead. * * · no `fallback`. Rendering plausible-looking content with no * sign the server is down is worse than a visible error. * * `notFound` is separated from `error` because they are different * pages: a 404 is a slug that doesn't exist and retrying won't help, * anything else is worth a Try again button. * * A null `path` means there is nothing to ask for, and the hook * reports notFound rather than loading forever. Callers build the * path with detailPath() from hrefs.ts, which returns null for an * id that could never be real — "undefined" chief among them. */ import { useCallback, useEffect, useState } from 'react' import { get, invalidate, ApiError } from './api.js' export type Resource = { data: T | null loading: boolean error: string | null notFound: boolean reload: () => void } export function useRecord(path: string | null, key: string): Resource { const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [notFound, setNotFound] = useState(false) const [attempt, setAttempt] = useState(0) const reload = useCallback(() => { if (path) invalidate(path) setAttempt((n) => n + 1) }, [path]) useEffect(() => { // Nothing to ask for. Distinguished from "not asked yet" by the // caller: useContent passes null only when the id is unusable, // and an unusable id is a 404 as far as the visitor is // concerned. if (!path) { setData(null) setError(null) setNotFound(true) setLoading(false) return undefined } let live = true async function load() { setLoading(true) setError(null) setNotFound(false) try { const body = await get | null>(path as string) if (!live) return // A 200 with the key absent is a server-side shaping bug, // not an empty record. Say so rather than rendering a page // full of blanks. const record = body?.[key] if (record === undefined) { setError(`The server sent no "${key}".`) setData(null) return } setData(record as T) } catch (err) { if (!live) return if (err instanceof ApiError && err.status === 404) { setNotFound(true) setData(null) return } setError( err instanceof ApiError ? err.message : "Couldn't reach the server.", ) setData(null) } finally { if (live) setLoading(false) } } load() return () => { live = false } }, [path, key, attempt]) return { data, loading, error, notFound, reload } }