@hyper-fetch/react
Version:
React hooks and utils for the hyper-fetch
333 lines (294 loc) • 11.2 kB
text/typescript
/* eslint-disable @typescript-eslint/naming-convention */
import { useDidUpdate } from "@better-hooks/lifecycle";
import type {
ExtractErrorType,
CacheValueType,
ExtractResponseType,
RequestInstance,
ExtractAdapterType,
ExtractAdapterExtraType,
ResponseType,
} from "@hyper-fetch/core";
import { scopeKey } from "@hyper-fetch/core";
import { useCallback, useRef, useSyncExternalStore } from "react";
import { isEqual } from "utils";
import type {
UseTrackedStateActions,
UseTrackedStateType,
UseTrackedStateProps,
UseTrackedStateReturn,
} from "./use-tracked-state.types";
import {
getInitialState,
getIsInitiallyLoading,
getShouldClearState,
getValidCacheData,
isStaleCacheData,
} from "./use-tracked-state.utils";
/**
*
* @param request
* @param initialResponse
* @param dispatcher
* @param dependencies
* @internal
*/
export const useTrackedState = <T extends RequestInstance>({
request,
dispatcher,
initialResponse,
deepCompare,
dependencyTracking,
keepPreviousData = "auto",
disabled,
revalidate,
}: UseTrackedStateProps<T>): UseTrackedStateReturn<T> => {
const { client, cacheKey, queryKey, staleTime, unstable_responseMapper } = request;
const { cache } = client;
const state = useRef<UseTrackedStateType<T>>(getInitialState({ initialResponse, dispatcher, request, disabled }));
const renderKeys = useRef<Array<keyof UseTrackedStateType<T>>>([]);
const isProcessingData = useRef("");
const previousCacheKey = useRef(cacheKey);
// ******************
// useSyncExternalStore
// ******************
const versionRef = useRef(0);
const listenerRef = useRef<(() => void) | null>(null);
const subscribe = useCallback((listener: () => void) => {
listenerRef.current = listener;
return () => {
listenerRef.current = null;
};
}, []);
const getSnapshot = useCallback(() => versionRef.current, []);
useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const emitChange = () => {
versionRef.current += 1;
listenerRef.current?.();
};
// ******************
// Utils
// ******************
const getStaleStatus = (): boolean => {
const cacheData = cache.get(scopeKey(cacheKey, request.scope));
return !cacheData || isStaleCacheData(staleTime, cacheData?.responseTimestamp);
};
// ******************
// Dependency Tracking
// ******************
const renderKeyTrigger = (keys: Array<keyof UseTrackedStateType>) => {
const shouldRerender = renderKeys.current.some((renderKey) => keys.includes(renderKey));
if (shouldRerender) emitChange();
};
const setRenderKey = (renderKey: keyof UseTrackedStateType) => {
if (!renderKeys.current.includes(renderKey)) {
renderKeys.current.push(renderKey);
}
};
// ******************
// Cache initialization
// ******************
useDidUpdate(
() => {
const oldKey = previousCacheKey.current;
previousCacheKey.current = cacheKey;
// Get cache state
const cacheData = cache.get<ExtractResponseType<T>, ExtractErrorType<T>>(cacheKey);
const cacheState = getValidCacheData<T>(request, initialResponse, cacheData);
// Determine whether to clear state based on keepPreviousData mode
const shouldClear = getShouldClearState(keepPreviousData, oldKey, cacheKey);
if (shouldClear && !cacheState) {
// Reset state to initial values when no cached data exists for the new key
const resetState = getInitialState({ initialResponse, dispatcher, request, disabled, revalidate });
state.current = resetState;
renderKeyTrigger(Object.keys(state.current) as (keyof UseTrackedStateType<T>)[]);
} else if (cacheState) {
state.current.loading = getIsInitiallyLoading({
queryKey: scopeKey(request.queryKey, request.scope),
dispatcher,
disabled,
revalidate,
hasState: true,
});
// eslint-disable-next-line @typescript-eslint/no-use-before-define
setCacheData(cacheState);
}
// When state is preserved and no cache exists (e.g. query param change in "auto" mode),
// don't update loading on the ref — let the dispatcher's loading event handle it (#126)
},
[cacheKey, queryKey],
true,
);
// ******************
// Turn off dependency tracking
// ******************
useDidUpdate(
() => {
const handleDependencyTracking = () => {
if (!dependencyTracking) {
Object.keys(state.current).forEach((key) => setRenderKey(key as Parameters<typeof setRenderKey>[0]));
}
};
handleDependencyTracking();
},
[dependencyTracking],
true,
);
// ******************
// Cache data handler
// ******************
const handleCompare = (firstValue: unknown, secondValue: unknown) => {
if (typeof deepCompare === "function") {
return deepCompare(firstValue, secondValue);
}
if (deepCompare) {
return isEqual(firstValue, secondValue);
}
return false;
};
const handleCacheData = (
cacheData: CacheValueType<ExtractResponseType<T>, ExtractErrorType<T>, ExtractAdapterType<T>>,
) => {
const newStateValues: UseTrackedStateType<T> = {
data: cacheData.data,
error: cacheData.error,
status: cacheData.status,
success: cacheData.success,
extra: cacheData.extra as ExtractAdapterExtraType<ExtractAdapterType<T>>,
retries: cacheData.retries,
responseTimestamp: new Date(cacheData.responseTimestamp),
requestTimestamp: new Date(cacheData.requestTimestamp),
loading: dispatcher.hasRunningRequests(queryKey),
};
const changedKeys = Object.keys(newStateValues).filter((key) => {
const keyValue = key as keyof UseTrackedStateType<T>;
const firstValue = state.current[keyValue];
const secondValue = newStateValues[keyValue];
return !handleCompare(firstValue, secondValue);
}) as unknown as (keyof UseTrackedStateType<T>)[];
state.current = {
...state.current,
...newStateValues,
};
renderKeyTrigger(changedKeys);
};
const setIsDataProcessing = ({
processingCacheKey,
isProcessing,
}: {
processingCacheKey: string;
isProcessing: boolean;
}) => {
if (isProcessing) {
isProcessingData.current = processingCacheKey;
}
// Do not turn off other keys processing
else if (isProcessingData.current === cacheKey) {
isProcessingData.current = "";
}
};
const getIsDataProcessing = (processingCacheKey: string) => {
return isProcessingData.current === processingCacheKey;
};
const setCacheData = (
cacheData: CacheValueType<ExtractResponseType<T>, ExtractErrorType<T>, ExtractAdapterType<T>>,
): Promise<void> | void => {
setIsDataProcessing({ processingCacheKey: cacheKey, isProcessing: true });
const data = unstable_responseMapper
? unstable_responseMapper(cacheData as ResponseType<any, any, ExtractAdapterType<T>>)
: cacheData;
if (data instanceof Promise) {
return (async () => {
const promiseData = await data;
handleCacheData({ ...cacheData, ...promiseData } as CacheValueType<
ExtractResponseType<T>,
ExtractErrorType<T>,
ExtractAdapterType<T>
>);
setIsDataProcessing({ processingCacheKey: cacheKey, isProcessing: false });
})();
}
setIsDataProcessing({ processingCacheKey: cacheKey, isProcessing: false });
return handleCacheData({ ...cacheData, ...data } as CacheValueType<
ExtractResponseType<T>,
ExtractErrorType<T>,
ExtractAdapterType<T>
>);
};
// ******************
// Actions
// ******************
const actions: UseTrackedStateActions<T> = {
setData: (data) => {
state.current.data = data instanceof Function ? data(state.current.data || null) : data;
renderKeyTrigger(["data"]);
},
setError: (error) => {
state.current.error = error instanceof Function ? error(state.current.error || null) : error;
renderKeyTrigger(["error"]);
},
setLoading: (loading) => {
const value = loading instanceof Function ? loading(state.current.loading) : loading;
if (value === state.current.loading) return;
state.current.loading = value;
renderKeyTrigger(["loading"]);
},
setStatus: (status) => {
const value = status instanceof Function ? status(state.current.status) : status;
if (value === state.current.status) return;
state.current.status = status instanceof Function ? status(state.current.status || null) : status;
renderKeyTrigger(["status"]);
},
setSuccess: (success) => {
const value = success instanceof Function ? success(state.current.success || false) : success;
if (value === state.current.success) return;
state.current.success = success instanceof Function ? success(state.current.success || false) : success;
renderKeyTrigger(["success"]);
},
setExtra: (extra) => {
const value = extra instanceof Function ? extra(state.current.extra) : extra;
if (value === state.current.extra) return;
state.current.extra = extra instanceof Function ? extra(state.current.extra) : extra;
renderKeyTrigger(["extra"]);
},
setRetries: (retries) => {
const value = retries instanceof Function ? retries(state.current.retries || 0) : retries;
if (value === state.current.retries) return;
state.current.retries = retries instanceof Function ? retries(state.current.retries || 0) : retries;
renderKeyTrigger(["retries"]);
},
setResponseTimestamp: (timestamp) => {
const value = timestamp instanceof Function ? timestamp(state.current.responseTimestamp) : timestamp;
if (value === state.current.responseTimestamp) return;
const getTimestamp = (prev: Date | null) => {
return timestamp instanceof Function ? timestamp(prev ? new Date(prev) : null) : timestamp;
};
state.current.responseTimestamp = getTimestamp(state.current.responseTimestamp);
renderKeyTrigger(["responseTimestamp"]);
},
setRequestTimestamp: (timestamp) => {
const value = timestamp instanceof Function ? timestamp(state.current.requestTimestamp) : timestamp;
if (value === state.current.requestTimestamp) return;
const getTimestamp = (prev: Date | null) => {
return timestamp instanceof Function ? timestamp(prev ? new Date(prev) : null) : timestamp;
};
state.current.requestTimestamp = getTimestamp(state.current.requestTimestamp);
renderKeyTrigger(["requestTimestamp"]);
},
clearState: () => {
state.current = {
data: null,
error: null,
loading: false,
status: null,
success: false,
extra: null,
retries: 0,
responseTimestamp: null,
requestTimestamp: null,
} as UseTrackedStateType<T>;
renderKeyTrigger(Object.keys(state.current) as (keyof UseTrackedStateType<T>)[]);
},
};
return [state.current, actions, { setRenderKey, setCacheData, getStaleStatus, getIsDataProcessing }];
};