@hyper-fetch/react
Version:
React hooks and utils for the hyper-fetch
314 lines (281 loc) • 9.6 kB
text/typescript
import { useRef } from "react";
import { useDidUpdate, useForceUpdate } from "@better-hooks/lifecycle";
import {
ExtractErrorType,
CacheValueType,
ExtractResponseType,
RequestInstance,
ExtractAdapterType,
ExtractAdapterExtraType,
} from "@hyper-fetch/core";
import { isEqual } from "utils";
import {
UseTrackedStateActions,
UseTrackedStateType,
UseTrackedStateProps,
UseTrackedStateReturn,
} from "./use-tracked-state.types";
import {
getDetailsState,
getInitialState,
getIsInitiallyLoading,
getValidCacheData,
isStaleCacheData,
} from "./use-tracked-state.utils";
/**
*
* @param request
* @param initialData
* @param dispatcher
* @param dependencies
* @internal
*/
export const useTrackedState = <T extends RequestInstance>({
request,
dispatcher,
initialData,
deepCompare,
dependencyTracking,
defaultCacheEmitting = true,
/**
* useFetch only
*/
disabled,
/**
* useFetch only
*/
revalidate,
}: UseTrackedStateProps<T>): UseTrackedStateReturn<T> => {
const { client, cacheKey, queueKey, cacheTime, responseMapper } = request;
const { cache, requestManager } = client;
const forceUpdate = useForceUpdate();
const state = useRef<UseTrackedStateType<T>>(
getInitialState({ initialResponse: initialData, dispatcher, request, disabled }),
);
const renderKeys = useRef<Array<keyof UseTrackedStateType<T>>>([]);
const isProcessingData = useRef("");
// ******************
// Utils
// ******************
const getStaleStatus = (): boolean => {
const cacheData = cache.get(cacheKey);
return !cacheData || isStaleCacheData(cacheTime, cacheData?.timestamp);
};
// ******************
// Dependency Tracking
// ******************
const renderKeyTrigger = (keys: Array<keyof UseTrackedStateType>) => {
const shouldRerender = renderKeys.current.some((renderKey) => keys.includes(renderKey));
if (shouldRerender) forceUpdate();
};
const setRenderKey = (renderKey: keyof UseTrackedStateType) => {
if (!renderKeys.current.includes(renderKey)) {
renderKeys.current.push(renderKey);
}
};
// ******************
// Cache initialization
// ******************
useDidUpdate(
() => {
// Handle initial loading state
state.current.loading = dispatcher.hasRunningRequests(queueKey);
// Get cache state
const cacheData = cache.get<
ExtractResponseType<T>,
ExtractErrorType<T>,
ExtractAdapterExtraType<ExtractAdapterType<T>>
>(cacheKey);
const cacheState = getValidCacheData<T>(request, initialData, cacheData);
// Handle initial loading state
state.current.loading = getIsInitiallyLoading({
queryKey: request.queueKey,
dispatcher,
disabled,
revalidate,
hasState: !!cacheState,
});
const hasInitialState = isEqual(initialData?.data, state.current.data);
const hasState = !!(state.current.data || state.current.error) && !hasInitialState;
const shouldLoadInitialCache = !hasState && !!state.current.data;
const shouldRemovePreviousData = hasState && !state.current.data;
if (cacheState || shouldLoadInitialCache || shouldRemovePreviousData) {
// Don't update the state when we are fetching data for new cacheKey
// So on paginated page we will have previous page access until the new one will be fetched
// However: When we have some cached data, we can use it right away
// eslint-disable-next-line @typescript-eslint/no-use-before-define
setCacheData(cacheState);
}
},
[cacheKey, queueKey],
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,
retries: cacheData.retries,
timestamp: new Date(cacheData.timestamp),
loading: dispatcher.hasRunningRequests(queueKey),
};
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 = responseMapper ? responseMapper(cacheData) : cacheData;
if (data instanceof Promise) {
return (async () => {
const promiseData = await data;
handleCacheData({ ...cacheData, ...promiseData });
setIsDataProcessing({ processingCacheKey: cacheKey, isProcessing: false });
})();
}
setIsDataProcessing({ processingCacheKey: cacheKey, isProcessing: false });
return handleCacheData({ ...cacheData, ...data });
};
// ******************
// Actions
// ******************
const actions: UseTrackedStateActions<T> = {
setData: (data, emitToCache = defaultCacheEmitting) => {
if (emitToCache) {
const currentState = state.current;
cache.set(request, { ...currentState, ...getDetailsState(state.current), success: true, data });
} else {
state.current.data = data;
renderKeyTrigger(["data"]);
}
},
setError: (error, emitToCache = defaultCacheEmitting) => {
if (emitToCache) {
const currentState = state.current;
cache.set(request, { ...currentState, ...getDetailsState(state.current), success: false, error });
} else {
state.current.error = error;
renderKeyTrigger(["error"]);
}
},
setLoading: (loading, emitToHooks = true) => {
if (emitToHooks) {
requestManager.events.emitLoading(queueKey, "", {
queueKey,
requestId: "",
loading,
isRetry: false,
isOffline: false,
});
} else if (loading !== state.current.loading) {
state.current.loading = loading;
renderKeyTrigger(["loading"]);
}
},
setStatus: (status, emitToCache = defaultCacheEmitting) => {
if (emitToCache) {
const currentState = state.current;
cache.set(request, { ...currentState, ...getDetailsState(state.current), status });
} else {
state.current.status = status;
renderKeyTrigger(["status"]);
}
},
setSuccess: (success, emitToCache = defaultCacheEmitting) => {
if (emitToCache) {
const currentState = state.current;
cache.set(request, { ...currentState, ...getDetailsState(state.current), success });
} else {
state.current.success = success;
renderKeyTrigger(["success"]);
}
},
setExtra: (extra, emitToCache = defaultCacheEmitting) => {
if (emitToCache) {
const currentState = state.current;
cache.set(request, { ...currentState, ...getDetailsState(state.current), extra });
} else {
state.current.extra = extra;
renderKeyTrigger(["extra"]);
}
},
setRetries: (retries, emitToCache = defaultCacheEmitting) => {
if (emitToCache) {
const currentState = state.current;
cache.set(request, { ...currentState, ...getDetailsState(state.current, { retries }) });
} else {
state.current.retries = retries;
renderKeyTrigger(["retries"]);
}
},
setTimestamp: (timestamp, emitToCache = defaultCacheEmitting) => {
if (emitToCache) {
const currentState = state.current;
cache.set(request, { ...currentState, ...getDetailsState(state.current, { timestamp: +timestamp }) });
} else {
state.current.timestamp = timestamp;
renderKeyTrigger(["timestamp"]);
}
},
};
return [state.current, actions, { setRenderKey, setCacheData, getStaleStatus, getIsDataProcessing }];
};