How to cook up a powerful React async component using hooks (and no Suspense)

Introduction

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.

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 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.

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)
    }
}

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.

// 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
}

Utilities

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))
    )
}

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.

28