Last active
March 6, 2023 11:45
-
-
Save strlns/536c0ed01f2b9d32ce73b250b40b736e to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* What is this? | |
* | |
* fetch Wrapper for GET requests in React: Cache responses that don't change during the sessionStorage lifecycle | |
* (see https://beta.reactjs.org/learn/you-might-not-need-an-effect#fetching-data) | |
* | |
* If you have to deal with complicated requests, don't use this, use a more complete solution, | |
* e.g. @tanstack/react-query | |
* This is only usable for read-only queries to an API | |
* which returns static results during the page lifecycle. | |
*/ | |
import { useCallback, useRef, useState } from "react"; | |
import { useDeepCompareEffect } from "./useDeepCompareEffect"; | |
import useOnMount from "./useOnMount"; | |
import usePrevious from "./usePrevious"; | |
/** | |
Wrap functions for data fetching with a SessionStorage cache. | |
This ensures two things: | |
1) Deduplication of requests | |
2) No stale data is ever served when many requests are fired in quick succession. | |
3) Fetching isn't done twice in development while React.StrictMode is enabled. | |
For more explanation on point 1) see: | |
https://reactjs.org/docs/strict-mode.html | |
For a quick explanation on point 2) see: | |
https://beta.reactjs.org/learn/you-might-not-need-an-effect#fetching-data | |
The identities of fetchData, canSkipQuery and modifyCacheKeyCallback | |
MUST be stable, if they change, a state update will result. | |
Important @todo: | |
This assumes that results are cacheable in sessionStorage (GET: same request, same response). | |
If this is not true for your project and you still want to use this, the options are: | |
- Remove/replace this whole hook, e.g. with react-query | |
- Set a very short maximum cache lifetime | |
- Return a function to invalidate the session cache, this poses complicated problems that should | |
not occur here (multiple users might access the same remote object and change it, client cannot now when to | |
invalidate). | |
If the remote object is only ever managed by a single user, this would be good enough™ | |
*/ | |
export default useCachedFetch; | |
export const CanSkipQueryReturnValues = { | |
DontSkip: 0, | |
ReturnInitialResult: 1, | |
ReturnPreviousResult: 2, | |
} as const; | |
export type CanSkipQueryReturnValue = | |
(typeof CanSkipQueryReturnValues)[keyof typeof CanSkipQueryReturnValues]; | |
export type FetchData<TResult, TQuery> = ( | |
queryURL: string, | |
query: TQuery | |
) => Promise<TResult>; | |
const defaultCanSkipQuery = () => CanSkipQueryReturnValues.DontSkip; | |
/* | |
Return an array of queries that the result should be set for in cache. | |
Reason: Backend might inform us that the result applies to a modified query. | |
If this returns null, the query is not modified before being serialized as | |
cache key. | |
*/ | |
export type ModifyCacheKeyCallback<TResult, TQuery> = ( | |
query: TQuery, | |
result: TResult | |
) => TQuery[] | null; | |
const defaultModifyCacheKeyCallback = () => null; | |
function useCachedFetch<TResult, TQuery = unknown>( | |
initialValue: TResult, | |
query: TQuery, | |
queryURL: string, | |
fetchData: FetchData<TResult, TQuery>, | |
//set to false by default as sessionstorage caching currently does not respect response cache headers. | |
//prefer duplicated requests for now. | |
enableCaching = false, | |
sessionStoragePrefix: string, | |
canSkipQuery: CanSkipQuery<TResult, TQuery> = defaultCanSkipQuery, | |
modifyCacheKeyCallback: ModifyCacheKeyCallback< | |
TResult, | |
TQuery | |
> = defaultModifyCacheKeyCallback | |
): [TResult, boolean, string?] { | |
const [data, setData] = useState<TResult>(initialValue); | |
const [isFetching, setIsFetching] = useState(false); | |
const [error, setError] = useState<string | undefined>(); | |
const canUseSessionStorage = useRef(false); | |
const previousQuery = usePrevious(query); | |
useOnMount(() => { | |
try { | |
const key = `${sessionStoragePrefix}StorageTest`; | |
sessionStorage.setItem(key, "1"); | |
canUseSessionStorage.current = | |
enableCaching && Boolean(sessionStorage.getItem(key)); | |
sessionStorage.removeItem(key); | |
} catch { | |
canUseSessionStorage.current = false; | |
} | |
}); | |
const fetchDataCached = useCallback( | |
async (queryURL: string, query: TQuery) => { | |
const getCacheKey = (query: TQuery, queryURL: string) => | |
`${sessionStoragePrefix}_${JSON.stringify([queryURL, query])}`; | |
setIsFetching(true); | |
const cacheKey = getCacheKey(query, queryURL); | |
if (canUseSessionStorage.current) { | |
const cachedResultString = sessionStorage.getItem(cacheKey); | |
if (cachedResultString) { | |
try { | |
return JSON.parse(cachedResultString) as TResult; | |
} catch { | |
//nothing to handle for now | |
} | |
} | |
} | |
const result = await fetchData(queryURL, query); | |
if (canUseSessionStorage.current) { | |
const modifiedQueries = modifyCacheKeyCallback(query, result); | |
const cacheKeys = | |
modifiedQueries === null | |
? [cacheKey] | |
: modifiedQueries.map((query) => getCacheKey(query, queryURL)); | |
for (const key of cacheKeys) { | |
sessionStorage.setItem(key, JSON.stringify(result)); | |
} | |
} | |
return result; | |
}, | |
[sessionStoragePrefix, fetchData, modifyCacheKeyCallback] | |
); | |
useDeepCompareEffect(() => { | |
let isStale = false; | |
(async () => { | |
switch (canSkipQuery(query, data, previousQuery)) { | |
case CanSkipQueryReturnValues.ReturnInitialResult: | |
setIsFetching(false); | |
setData(initialValue); | |
setError(undefined); | |
break; | |
case CanSkipQueryReturnValues.ReturnPreviousResult: | |
setIsFetching(false); | |
setError(undefined); | |
break; | |
case CanSkipQueryReturnValues.DontSkip: | |
default: | |
try { | |
const result = await fetchDataCached(queryURL, query); | |
if (!isStale) { | |
setIsFetching(false); | |
setData(result); | |
setError(undefined); | |
} | |
} catch (e) { | |
setIsFetching(false); | |
setError(e instanceof Error ? e.message : String(e)); | |
} | |
} | |
})(); | |
return () => { | |
isStale = true; | |
}; | |
}, [query, queryURL, fetchData, fetchDataCached]); | |
return [data, isFetching, error]; | |
} | |
/* | |
This is the type of an optional callback that | |
determines which queries can be skipped, | |
depending on the query, the previous query and the previous result. | |
Skipping can mean either: | |
a) return the initial result | |
b) leave the previous result unchanged | |
Skipping a query means that not even a cache lookup is performed. | |
This is useful to prevent unnecessary state changes for queries where we | |
can already know the result in advance. | |
By default, no queries are skipped. | |
*/ | |
export type CanSkipQuery<TResult, TQuery> = ( | |
query: TQuery, | |
resultFromPreviousQuery: TResult, | |
previousQuery?: TQuery | |
) => CanSkipQueryReturnValue; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment