Last active
March 18, 2023 20:13
-
-
Save simonrelet/0965e0482cd175750dd83183770973bd to your computer and use it in GitHub Desktop.
React hooks for asynchronous calls
This file contains 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 React from 'react' | |
/** | |
* @typedef {object} State The state of asynchronous hooks. | |
* @property {object | null} error The error. | |
* @property {boolean} pending Whether the call is pending. | |
* @property {any | null} result The result of the asynchronous call. | |
*/ | |
/** @type {State} */ | |
const initialState = { | |
error: null, | |
pending: false, | |
data: null, | |
} | |
/** | |
* The reducer of asynchronous hooks. | |
* | |
* @param {State} state The current state. | |
* @param {{ type: string, data?: any, error?: object }} action The action. | |
* @returns {State} The new state. | |
*/ | |
function reducer(state, action) { | |
switch (action.type) { | |
case 'START': { | |
return { ...state, pending: true } | |
} | |
case 'SUCCESS': { | |
return { ...state, pending: false, error: null, data: action.data } | |
} | |
case 'ERROR': | |
default: { | |
return { ...state, pending: false, error: action.error } | |
} | |
} | |
} | |
/** | |
* @callback AsyncMemoCallback | |
* @returns {any} The memoized value. | |
*/ | |
/** | |
* Asynchronous version of `React.useMemo`. | |
* | |
* @param {AsyncMemoCallback} callback The callback. | |
* @param {any[]} [deps] The dependencies. | |
* @returns {[any, State]} | |
*/ | |
export function useAsyncMemo(callback, deps) { | |
const [state, dispatch] = React.useReducer(reducer, initialState) | |
React.useEffect( | |
() => { | |
let canceled = false | |
async function doWork() { | |
dispatch({ type: 'START' }) | |
try { | |
const data = await callback() | |
if (!canceled) { | |
dispatch({ type: 'SUCCESS', data }) | |
} | |
} catch (error) { | |
if (!canceled) { | |
dispatch({ type: 'ERROR', error }) | |
} | |
} | |
} | |
doWork() | |
return () => { | |
canceled = true | |
} | |
}, | |
// We don't add `dispatch` and `callback` to deps to let the caller manage | |
// them himself. | |
// This is _ok_ as `dispatch` will never change and the latest `callback` | |
// will only be used if `deps` changes, which is the behaviour of | |
// `React.useMemo`. | |
deps, | |
) | |
return [state.data, state] | |
} | |
/** | |
* @callback AsyncCallbackCallback | |
* @param {...any} args The parameters. | |
* @returns {any} A value. | |
*/ | |
/** | |
* Asynchronous version of `React.useCallback`. | |
* | |
* @param {AsyncCallbackCallback} callback The callback. | |
* @param {any[]} [deps] The dependencies. | |
* @returns {[AsyncCallbackCallback, State]} | |
*/ | |
export function useAsyncCallback(callback, deps) { | |
const [state, dispatch] = React.useReducer(reducer, initialState) | |
const cancelPrevious = React.useRef(null) | |
const run = React.useCallback( | |
async (...args) => { | |
if (cancelPrevious.current != null) { | |
cancelPrevious.current() | |
} | |
let canceled = false | |
cancelPrevious.current = () => { | |
canceled = true | |
} | |
dispatch({ type: 'START' }) | |
try { | |
const data = await callback(...args) | |
if (!canceled) { | |
dispatch({ type: 'SUCCESS', data }) | |
} | |
} catch (error) { | |
if (!canceled) { | |
dispatch({ type: 'ERROR', error }) | |
} | |
} | |
}, | |
// We don't add `dispatch` and `callback` to deps to let the caller manage | |
// them himself. | |
// This is _ok_ as `dispatch` will never change and the latest `callback` | |
// will only be used if `deps` changes, which is the behaviour of | |
// `React.useEffect`. | |
deps, | |
) | |
return [run, state] | |
} |
This file contains 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 React from 'react' | |
import { fetchDedupe } from 'fetch-dedupe' | |
import { Loader, Login } from './components' | |
import { useAsyncCallback, useAsyncMemo } from './asynchronous' | |
export function Books() { | |
const [books] = useAsyncMemo(() => fetchData('/api/books'), []) | |
if (books == null) { | |
return <Loader /> | |
} | |
return <p>{books.length} books</p> | |
} | |
export function Book({ id }) { | |
const [book] = useAsyncMemo(() => fetchData(`/api/books/${id}`), [id]) | |
if (book == null) { | |
return <Loader /> | |
} | |
return <p>{book.title}</p> | |
} | |
export function User({ id }) { | |
const [user, { error, pending }] = useAsyncMemo( | |
() => fetchData(`/api/users/${id}`), | |
[id] | |
) | |
if (error == null && user == null) { | |
return <Loader /> | |
} | |
if (error != null) { | |
return <Login /> | |
} | |
return <p>Hello {user.name}</p> | |
} | |
export function LogoutButton() { | |
const [logout] = useAsyncCallback( | |
() => fetchData('/api/logout', { method: 'POST' }), | |
[] | |
) | |
return <button onClick={logout}>Logout</button> | |
} | |
/** | |
* Small wrapper around fetch-dedupe. | |
* This function returns the data and throws in case of failure. | |
* | |
* @param {...any} args Parameters for fetch-dedupe. | |
* @returns {any} The payload of the request. | |
*/ | |
async function fetchData(...args) { | |
const response = await fetchDedupe(...args) | |
if (!response.ok) { | |
throw new Error(response.statusText) | |
} | |
return response.data | |
} |
You are right, no idea what i have seen (or not seen) that day. Sorry
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
is it possible that useAsyncCallback never actually checks if previousCanceled is true? It seems it ignores the case when it was canceled
https://gist.github.com/simonrelet/0965e0482cd175750dd83183770973bd#file-asynchronous-js-L114