Skip to content

Instantly share code, notes, and snippets.

@simonrelet
Last active March 18, 2023 20:13
Show Gist options
  • Star 13 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • 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 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