Files
loyaly_cutomerweb/src/shared/hooks/useResource.ts
2026-09-28 23:53:28 +05:30

151 lines
5.6 KiB
TypeScript

'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;
/**
* TEMPORARY — set by useSampleFallback() when the real endpoint had nothing
* and an example is on screen instead. PanelCard shows the "Sample data"
* token from it. Goes with shared/mocks.
*/
isSample?: boolean;
};
/** 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>;
}