Skip to content

Instantly share code, notes, and snippets.

@simonrelet
Last active March 18, 2023 20:13
Show Gist options
  • Save simonrelet/0965e0482cd175750dd83183770973bd to your computer and use it in GitHub Desktop.
Save simonrelet/0965e0482cd175750dd83183770973bd to your computer and use it in GitHub Desktop.
React hooks for asynchronous calls
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]
}
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
}
@theluk
Copy link

theluk commented Jul 9, 2021

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

@simonrelet
Copy link
Author

Hello @theluk, useAsyncCallback checks whether the callback has been canceled in case of success (line #122) or failure (line #126), so it should not ignore it.

Do you have a reproductible case I can look at?

@theluk
Copy link

theluk commented Jul 16, 2021

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