An Interest In:
Web News this Week
- April 1, 2024
- March 31, 2024
- March 30, 2024
- March 29, 2024
- March 28, 2024
- March 27, 2024
- March 26, 2024
How to cook up a powerful React async component using hooks
Photo by Adrian Infernus on Unsplash
Introduction
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.
The Code
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 which gets 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
.
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
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 } }, [localDeps, myId, ...deps]) // The dependencies return [result, restart] // 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) { setResult((existing) => { if ( typeof existing === "object" && !Array.isArray(existing) && typeof newValue === "object" && !Array.isArray(newValue) && newValue ) { return { ...existing, ...newValue } } else { return newValue } }) } // Update the local deps to cause a restart function restart() { setDeps((a) => a + 1) }}
The Test Code
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.
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.
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}
Utilities
We use a delay
and a parallel
function here, this is what they look like:
// Promise for a delay in millisecondsfunction delay(time = 100) { return new Promise((resolve) => setTimeout(resolve, time))}// Promise for the results of the parameters which can be// either functions or promisesfunction parallel(...items) { return Promise.all( items.map((item) => (typeof item === "function" ? item() : item)) )}
Conclusion
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.
Original Link: https://dev.to/miketalbot/how-to-make-a-powerful-react-async-component-122d
Dev To
An online community for sharing and discovering great ideas, having debates, and making friendsMore About this Source Visit Dev To