Skip to content

Instantly share code, notes, and snippets.

@simonrelet
Last active June 4, 2021 22:27
Show Gist options
  • Save simonrelet/54f53cc9a4bdcb430dd4912b828f1c4a to your computer and use it in GitHub Desktop.
Save simonrelet/54f53cc9a4bdcb430dd4912b828f1c4a to your computer and use it in GitHub Desktop.
React Request's Fetch component transposed in hooks
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