Last active
June 4, 2021 22:27
-
-
Save simonrelet/54f53cc9a4bdcb430dd4912b828f1c4a to your computer and use it in GitHub Desktop.
React Request's Fetch component transposed in hooks
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
import { fetchDedupe, getRequestKey, isRequestInFlight } from 'fetch-dedupe' | |
import { useEffect, useRef, useState } from 'react' | |
// This in the Fetch component transposed in hooks. | |
// https://github.com/jamesplease/react-request/issues/199 | |
// https://github.com/jamesplease/react-request/blob/master/src/fetch.js | |
// This object is our cache. | |
// The keys of the object are requestKeys. | |
// The value of each key is a Response instance. | |
let responseCache = {} | |
const defaultHookOptions = { | |
afterFetch: () => {}, | |
beforeFetch: () => {}, | |
dedupe: true, | |
integrity: '', | |
method: 'get', | |
onResponse: () => {}, | |
referrer: 'about:client', | |
referrerPolicy: '', | |
requestName: 'anonymousRequest', | |
transformData: data => data, | |
} | |
// The docs state that this is not safe to use in an application. | |
// That's just because I am not writing tests, nor designing the API, around | |
// folks clearing the cache. | |
// This was only added to help out with testing your app. | |
// Use your judgment if you decide to use this in your app directly. | |
export function clearResponseCache() { | |
responseCache = {} | |
} | |
function isReadRequest(method) { | |
const uppercaseMethod = method.toUpperCase() | |
return ( | |
uppercaseMethod === 'GET' || | |
uppercaseMethod === 'HEAD' || | |
uppercaseMethod === 'OPTIONS' | |
) | |
} | |
export function useFetch(hookOptions = {}) { | |
hookOptions = Object.assign({}, defaultHookOptions, hookOptions) | |
// We use a single `useState` instead of one for each value to make sure that | |
// each `setState` is atomic. | |
// React in concurrent mode might render the component between two | |
// synchronously called `setState` and lead to incoherent state in our case. | |
const [ | |
{ fetching, response, data, error, url, requestKey }, | |
setState, | |
] = useState({ | |
fetching: false, | |
response: null, | |
data: null, | |
error: null, | |
url: null, | |
requestKey: hookOptions.requestKey || getRequestKey(hookOptions), | |
}) | |
const pendingRequestKey = useRef(null) | |
const didUnmount = useRef(false) | |
const pendingResponseReceivedInfo = useRef(null) | |
useEffect(() => { | |
if (!_isLazy()) { | |
_fetchData() | |
} | |
return () => { | |
didUnmount.current = true | |
_cancelExistingRequest('Component unmounted') | |
} | |
}, []) | |
useEffect(() => { | |
if (!_isLazy()) { | |
_fetchData() | |
} | |
}, [hookOptions.requestKey || getRequestKey(hookOptions)]) | |
function _isLazy() { | |
return typeof hookOptions.lazy === 'undefined' | |
? !isReadRequest(hookOptions.method) | |
: hookOptions.lazy | |
} | |
function _shouldCacheResponse() { | |
return typeof hookOptions.cacheResponse === 'undefined' | |
? isReadRequest(hookOptions.method) | |
: hookOptions.cacheResponse | |
} | |
function _getFetchPolicy() { | |
if (typeof hookOptions.fetchPolicy === 'undefined') { | |
return isReadRequest(hookOptions.method) ? 'cache-first' : 'network-only' | |
} else { | |
return hookOptions.fetchPolicy | |
} | |
} | |
function _cancelExistingRequest(reason) { | |
if (fetching && pendingRequestKey.current !== null) { | |
const abortError = new Error(reason) | |
// This is an effort to mimic the error that is created when a fetch is | |
// actually aborted using the AbortController API. | |
abortError.name = 'AbortError' | |
_onResponseReceived({ | |
...pendingResponseReceivedInfo.current, | |
error: abortError, | |
hittingNetwork: true, | |
}) | |
} | |
} | |
function _fetchRenderProp(options) { | |
return new Promise(resolve => { | |
// We wrap this in a setTimeout so as to avoid calls to `setState` in | |
// render, which React does not allow. | |
// | |
// tl;dr, the following code should never cause a React warning or error: | |
// | |
// `<Fetch children={({ doFetch }) => doFetch()} /> | |
setTimeout(() => { | |
_fetchData(options, true, resolve) | |
}) | |
}) | |
} | |
// When a subsequent request is made, it is important that the correct | |
// request key is used. This method computes the right key based on the | |
// options and props. | |
function _getRequestKey(options) { | |
// A request key in the options gets top priority | |
if (options && options.requestKey) { | |
return options.requestKey | |
} | |
// Otherwise, if we have no request key, but we do have options, then we | |
// recompute the request key based on these options. | |
// Note that if the URL, body, or method have not changed, then the request | |
// key should match the previous request key if it was computed. | |
// If you passed in a custom request key as a prop, then you will also need | |
// to pass in a custom key when you call `doFetch()`! | |
else if (options) { | |
const { url, method, body } = Object.assign({}, hookOptions, options) | |
return getRequestKey({ url, body, method }) | |
} | |
// Next in line is the the request key from props. | |
else if (hookOptions.requestKey) { | |
return hookOptions.requestKey | |
} | |
// Lastly, we compute the request key from the props. | |
else { | |
const { url, method, body } = hookOptions | |
return getRequestKey({ url, body, method }) | |
} | |
} | |
async function _fetchData(options, ignoreCache, resolve) { | |
// These are the things that we do not allow a user to configure in | |
// `options` when calling `doFetch()`. | |
// Perhaps we should, however. | |
const { requestName, dedupe, beforeFetch } = hookOptions | |
_cancelExistingRequest('New fetch initiated') | |
const requestKey = _getRequestKey(options) | |
const requestOptions = Object.assign({}, hookOptions, options) | |
pendingRequestKey.current = requestKey | |
const { | |
url, | |
body, | |
credentials, | |
headers, | |
method, | |
responseType, | |
mode, | |
cache, | |
redirect, | |
referrer, | |
referrerPolicy, | |
integrity, | |
keepalive, | |
signal, | |
} = requestOptions | |
const uppercaseMethod = method.toUpperCase() | |
const shouldCacheResponse = _shouldCacheResponse() | |
const init = { | |
body, | |
credentials, | |
headers, | |
method: uppercaseMethod, | |
mode, | |
cache, | |
redirect, | |
referrer, | |
referrerPolicy, | |
integrity, | |
keepalive, | |
signal, | |
} | |
const responseReceivedInfo = { | |
url, | |
init, | |
requestKey, | |
responseType, | |
} | |
// This is necessary because `options` may have overridden the props. | |
// If the request config changes, we need to be able to accurately cancel | |
// the in-flight request. | |
pendingResponseReceivedInfo.current = responseReceivedInfo | |
const fetchPolicy = _getFetchPolicy() | |
let cachedResponse | |
if (fetchPolicy !== 'network-only' && !ignoreCache) { | |
cachedResponse = responseCache[requestKey] | |
if (cachedResponse) { | |
_onResponseReceived({ | |
...pendingResponseReceivedInfo.current, | |
response: cachedResponse, | |
hittingNetwork: false, | |
stillFetching: fetchPolicy === 'cache-and-network', | |
}) | |
if (fetchPolicy === 'cache-first' || fetchPolicy === 'cache-only') { | |
return cachedResponse | |
} | |
} else if (fetchPolicy === 'cache-only') { | |
const cacheError = new Error( | |
`Response for "${requestName}" not found in cache.`, | |
) | |
_onResponseReceived({ | |
...pendingResponseReceivedInfo.current, | |
error: cacheError, | |
hittingNetwork: false, | |
}) | |
return cacheError | |
} | |
} | |
setState(state => ({ | |
...state, | |
requestKey, | |
url, | |
error: null, | |
fetching: true, | |
})) | |
const hittingNetwork = !isRequestInFlight(requestKey) || !dedupe | |
if (hittingNetwork) { | |
beforeFetch({ url, init, requestKey }) | |
} | |
try { | |
const res = await fetchDedupe(url, init, { | |
requestKey, | |
responseType, | |
dedupe, | |
}) | |
if (shouldCacheResponse) { | |
responseCache[requestKey] = res | |
} | |
if (pendingRequestKey.current === requestKey) { | |
_onResponseReceived({ | |
...pendingResponseReceivedInfo.current, | |
response: res, | |
hittingNetwork, | |
resolve, | |
}) | |
} | |
return res | |
} catch (error) { | |
if (pendingRequestKey.current === requestKey) { | |
_onResponseReceived({ | |
...pendingResponseReceivedInfo.current, | |
error, | |
cachedResponse, | |
hittingNetwork, | |
resolve, | |
}) | |
} | |
return error | |
} | |
} | |
function _onResponseReceived(options) { | |
options = Object.assign( | |
{ error: null, response: null, stillFetching: false }, | |
options, | |
) | |
pendingResponseReceivedInfo.current = null | |
if (!options.stillFetching) { | |
pendingRequestKey.current = null | |
} | |
let _data | |
// If our response succeeded, then we use that data. | |
if (options.response && options.response.data) { | |
_data = options.response.data | |
} else if (options.cachedResponse && options.cachedResponse.data) { | |
// This happens when the request failed, but we have cache-and-network | |
// specified. | |
// Although we pass along the failed response, we continue to pass in the | |
// cached data. | |
_data = options.cachedResponse.data | |
} | |
_data = _data ? hookOptions.transformData(_data) : null | |
// If we already have some data in state on error, then we continue to pass | |
// that data down. | |
// This prevents the data from being wiped when a request fails, which is | |
// generally not what people want. | |
// For more, see: GitHub Issue #154 | |
if (options.error && data) { | |
_data = data | |
} | |
const afterFetchInfo = { | |
url: options.url, | |
init: options.init, | |
requestKey: options.requestKey, | |
error: options.error, | |
failed: Boolean( | |
options.error || (options.response && !options.response.ok), | |
), | |
response: options.response, | |
data: _data, | |
didUnmount: Boolean(didUnmount.current), | |
} | |
if (typeof options.resolve === 'function') { | |
options.resolve(afterFetchInfo) | |
} | |
if (options.hittingNetwork) { | |
hookOptions.afterFetch(afterFetchInfo) | |
} | |
if (!didUnmount.current) { | |
setState(state => { | |
// We wrap this in a setTimeout so as to avoid calls to `setState` in | |
// state update. | |
setTimeout(() => | |
hookOptions.onResponse(options.error, options.response), | |
) | |
return { | |
...state, | |
url: options.url, | |
data: _data, | |
error: options.error, | |
response: options.response, | |
fetching: options.stillFetching, | |
requestKey: options.requestKey, | |
} | |
}) | |
} | |
} | |
return [ | |
data, | |
{ | |
requestName: hookOptions.requestName, | |
url, | |
fetching, | |
failed: Boolean(error || (response && !response.ok)), | |
response, | |
data, | |
requestKey, | |
error, | |
doFetch: _fetchRenderProp, | |
}, | |
] | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment