28
How to cook up a powerful React async component using hooks (and no Suspense)
Photo by Adrian Infernus on Unsplash
Frequently the core of our front end code revolves around calling services, and quite possibly, using the result of one service to call another. Writing async code piecemeal in React quickly becomes tedious and error prone and keeping the user up to date on the current progress has us jumping through hoops.
In this article we will look at a way to simplify async React code by building a custom hook out of some simple parts.
It's amazing what you can cook up by mixing together a few hooks. I thought I'd put together an overview of how to make a powerful useAsync hook that allows you to do all sorts of cool progress animations.
Here's a sneak peek at it updating multiple areas of a React app:
As you can see multiple parts of the interface update independently and we can restart the operation by changing a few deps - which cancels the previous operation.
For the purpose of this hook we are going to combine the useMemo
, useState
, and useRef
hooks to produce a useAsync
hook that takes an async function that is passed some utility functions which can be used to provide intermediate results as it executes, check whether the function should cancel and restart the operation.
Firstly, what we are after is producing a component that is made up of multiple parts that are independently updated. For testing, we will write an async function that runs two jobs in parallel and then combines the results at the end.
A basic wrapper App might look like this:
export default function App() {
const {
progress1 = null,
progress2 = null,
done = null
} = useAsync(runProcesses, [])
return (
<div className="App">
<div>{progress1}</div>
<div>{progress2}</div>
<div>{done}</div>
</div>
)
}
The one in the CodeSandbox is a bit fancier, using Material UI components, but it's basically this with bells on.
runProcesses
is the actual async function we want to run as a test. We'll come to that in a moment. First let's look at useAsync
.
So here's the idea:
- We want to return an object with keys that represent the various parts of the interface
- We want to start the async function when the dependencies change (and run it the first time)
- We want the async function to be able to check whether it should cancel after it has performed an async operation
- We want the async function to be able to supply part of the interface and have it returned to the outer component for rendering
- We want to be able to restart the process by calling a function
Lets map those to standard hooks:
- The return value can be a
useState({})
, this will let us update the result by supplying an object to be merged with the current state - We can use
useMemo
to start our function immediately as the dependencies change - We can check whether we should cancel by using a
useRef()
to hold the current dependencies and check if it's the same as the dependencies we had when we started the function. A closure will keep a copy of the dependencies on startup so we can compare them. - We can use another
useState()
to provide an additional "refresh" dependency
// Javascript version (both JS/TS in CodeSandbox)
const myId = Date.now() // Helps with Hot Module Reload
function useAsync(fn, deps = [], defaultValue = {}) {
// Maintain an internal id to allow for restart
const [localDeps, setDeps] = useState(0)
// Hold the value that will be returned to the caller
const [result, setResult] = useState(defaultValue)
// If the result is an object, decorate it with
// the restart function
if(typeof result === 'object') {
result.restart = restart
}
// Holds the currently running dependencies so
// we can compare them with set used to start
// the async function
const currentDeps = useRef()
// Use memo will call immediately that the deps
// change
useMemo(() => {
// Create a closure variable of the currentDeps
// and update the ref
const runningDeps = (currentDeps.current = [localDeps, myId, ...deps])
// Start the async function, passing it the helper
// functions
Promise.resolve(fn(update, cancelled, restart)).then((result) => {
// If the promise returns a value, use it
// to update what is rendered
result !== undefined && update(result)
})
// Closure cancel function compares the currentDeps
// ref with the closed over value
function cancelled() {
return runningDeps !== currentDeps.current
}
// Update the returned value, we can pass anything
// and the useAsync will return that - but if we pass
// an object, then we will merge it with the current values
function update(newValue) {
if(cancelled()) return
setResult((existing) => {
if (
typeof existing === "object" &&
!Array.isArray(existing) &&
typeof newValue === "object" &&
!Array.isArray(newValue) &&
newValue
) {
return { ...existing, ...newValue }
} else {
return newValue
}
})
}
}, [localDeps, myId, ...deps]) // The dependencies
return result
// Update the local deps to cause a restart
function restart() {
setDeps((a) => a + 1)
}
}
Ok so now we need to write something to test this. Normally your asyncs will be server calls and here we will just use a delayed loop to simulate this. Like a series of server calls though we will have a value being calculated and passed to 2 asynchronous functions that can run in parallel, when they have both finished we will combine the results. As the functions run we will update progress bars.
// TypeScript version (both JS/TS in CodeSandbox)
async function runProcesses(
update: UpdateFunction,
cancelled: CancelledFunction,
restart: RestartFunction
) {
update({ done: <Typography>Starting</Typography> })
await delay(200)
// Check if we should cancel
if (cancelled()) return
// Render something in the "done" slot
update({ done: <Typography>Running</Typography> })
const value = Math.random()
const results = await parallel(
progress1(value, update, cancelled),
progress2(value, update, cancelled)
)
// Check if we should cancel
if (cancelled()) return
return {
done: (
<Box>
<Typography variant="h6" gutterBottom>
Final Answer: {(results[0] / results[1]).toFixed(1)}
</Typography>
<Button variant="contained" color="primary" onClick={restart}>
Restart
</Button>
</Box>
)
}
}
This function does pretty much what I mentioned, it calculates a value (well it's a random!) - passes it to two more functions and when they are done it returns something to render in the done
slot.
As you can see we take an update
function that we can use to update the elements of the component. We also have a cancelled
function that we should check and return if it is true
.
Here is the code for one of the progress functions. It multiplies a value with a delay to make it async. Every step it updates a progress bar and finally replaces it with the result.
// TypeScript version (both JS/TS in CodeSandbox)
async function progress1(
value: number,
update: UpdateFunction,
cancelled: CancelledFunction
) {
for (let i = 0; i < 100; i++) {
value *= 1.6 - Math.random() / 5
await delay(50)
// Check if we should cancel
if (cancelled()) return
// Render a progress bar
update({
progress1: (
<LinearProgress
variant="determinate"
color="primary"
value={i}
/>
)
})
}
value = Math.round(value)
// When done, just render the final value
update({ progress1: <Typography>{value}</Typography> })
return value
}
We use a delay
and a parallel
function here, this is what they look like:
// Promise for a delay in milliseconds
function delay(time = 100) {
return new Promise((resolve) => setTimeout(resolve, time))
}
// Promise for the results of the parameters which can be
// either functions or promises
function parallel(...items) {
return Promise.all(
items.map((item) => (typeof item === "function" ? item() : item))
)
}
Well that about wraps it up. We've taken 3 standard hooks and created a powerful hook to enable complex asynchronous components.
The code (in TS and JS) is in the linked CodeSandbox at the top of this article.
28