first commit
This commit is contained in:
144
src/shared/hooks/useResource.ts
Normal file
144
src/shared/hooks/useResource.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {fetchEndpoint, endpointUrl} from '@/shared/services/httpClient';
|
||||
import type {Endpoint} from '@/shared/services/httpClient';
|
||||
import {isFailure} from '@/shared/types/api';
|
||||
import type {ApiFailure, ApiMeta} from '@/shared/types/api';
|
||||
|
||||
/**
|
||||
* Deliberately shaped like SWR so it can be replaced by SWR or TanStack Query
|
||||
* in exactly one file when a real backend arrives.
|
||||
*
|
||||
* 'empty' is a first-class status rather than something each caller derives
|
||||
* from `data.length === 0`. Getting that wrong is how "no results" ends up
|
||||
* rendering as an axis with no bars.
|
||||
*/
|
||||
export type AsyncState<T> =
|
||||
| {status: 'loading'; data: undefined; error: undefined}
|
||||
| {status: 'success'; data: T; error: undefined}
|
||||
| {status: 'empty'; data: T; error: undefined}
|
||||
| {status: 'error'; data: undefined; error: ApiFailure['error']};
|
||||
|
||||
export type Resource<T> = AsyncState<T> & {
|
||||
refetch: () => void;
|
||||
/**
|
||||
* True while a new request is in flight but previous data is still on
|
||||
* screen. Panels use this to dim rather than to blank.
|
||||
*/
|
||||
isRefreshing: boolean;
|
||||
/**
|
||||
* Response metadata, including the SERVER's clock. Anything computing a
|
||||
* relative deadline — days-to-expiry, "2 hours ago" — must measure against
|
||||
* this rather than Date.now(): calling Date.now() during render is impure,
|
||||
* disagrees between the SSR and hydration passes, and quietly reports the
|
||||
* wrong countdown to any user whose device clock is off.
|
||||
*/
|
||||
meta?: ApiMeta;
|
||||
};
|
||||
|
||||
/** What the fetch resolved to, tagged with the request it belongs to. */
|
||||
type Settled<T> =
|
||||
| {key: string; ok: true; data: T; meta?: ApiMeta}
|
||||
| {key: string; ok: false; error: ApiFailure['error']};
|
||||
|
||||
export function useResource<T>(
|
||||
endpoint: Endpoint<T> | null,
|
||||
opts?: {isEmpty?: (d: T) => boolean; initialData?: T},
|
||||
): Resource<T> {
|
||||
const {isEmpty, initialData} = opts ?? {};
|
||||
|
||||
// The URL IS the request identity. Keying on it means a store or range change
|
||||
// refetches while an unrelated re-render does not.
|
||||
const key = endpoint ? endpointUrl(endpoint) : null;
|
||||
|
||||
// Seeded from initialData with no meta — a caller-supplied payload has no
|
||||
// server timestamp to report.
|
||||
const [settled, setSettled] = useState<Settled<T> | null>(() =>
|
||||
initialData !== undefined && key
|
||||
? {key, ok: true, data: initialData}
|
||||
: null,
|
||||
);
|
||||
const [nonce, setNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!endpoint || !key) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
|
||||
// No setState for the loading state: it is DERIVED below from whether the
|
||||
// settled result matches the current key. Setting it here would be a
|
||||
// synchronous setState in an effect body — a cascading render, and the
|
||||
// thing react-hooks/set-state-in-effect exists to catch.
|
||||
fetchEndpoint(endpoint, controller.signal)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
setSettled(
|
||||
isFailure(res)
|
||||
? {key, ok: false, error: res.error}
|
||||
: {key, ok: true, data: res.data, meta: res.meta},
|
||||
);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
// An abort is a superseded request, not a failure to show the user.
|
||||
if (cancelled || (err as Error)?.name === 'AbortError') return;
|
||||
setSettled({
|
||||
key,
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'internal',
|
||||
message: (err as Error)?.message ?? 'Request failed',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
// `endpoint` is intentionally not a dep: `key` is its serialized identity,
|
||||
// and callers construct the object inline on every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, nonce]);
|
||||
|
||||
const refetch = useCallback(() => setNonce((n) => n + 1), []);
|
||||
|
||||
// Stale-while-revalidate.
|
||||
//
|
||||
// When the scope changes, the honest-but-wrong thing is to drop straight to
|
||||
// 'loading': the whole dashboard blanks to skeletons and every animated
|
||||
// counter restarts from zero. Merchants switch store and period constantly,
|
||||
// so that reads as the page breaking rather than as data arriving.
|
||||
//
|
||||
// Instead the previous result stays on screen until the new one lands, with
|
||||
// `isRefreshing` set. 'loading' is now reserved for the genuine first paint,
|
||||
// when there is nothing to show. A stale ERROR is not kept — showing a dead
|
||||
// error under a fresh request would be actively misleading.
|
||||
const isStale = !!settled && settled.key !== key;
|
||||
const usable = settled && (settled.ok || !isStale) ? settled : null;
|
||||
|
||||
const state: AsyncState<T> =
|
||||
!key || !usable
|
||||
? {status: 'loading', data: undefined, error: undefined}
|
||||
: usable.ok
|
||||
? {
|
||||
// Emptiness is derived here rather than inside the effect, so
|
||||
// `isEmpty` never has to be a dependency — no ref, no stale
|
||||
// closure, no ref mutation during render.
|
||||
status: (
|
||||
isEmpty
|
||||
? isEmpty(usable.data)
|
||||
: Array.isArray(usable.data) && usable.data.length === 0
|
||||
)
|
||||
? 'empty'
|
||||
: 'success',
|
||||
data: usable.data,
|
||||
error: undefined,
|
||||
}
|
||||
: {status: 'error', data: undefined, error: usable.error};
|
||||
|
||||
const meta = usable && usable.ok ? usable.meta : undefined;
|
||||
|
||||
return {...state, refetch, isRefreshing: isStale, meta} as Resource<T>;
|
||||
}
|
||||
Reference in New Issue
Block a user