Scenario is relatively simple: we have a long-running, on-demand calculation that occurs on a remote server. We want to memoize the result. Even though we are fetching asychronously from a remote resource, this isn't a side effect because we just want the result of this calculation to display to the user and we definitely don't want to do this on every render.
Problem: it seems that React.useMemo does not directly support Typescript's async/await and will return a promise:
//returns a promise:
let myMemoizedResult = React.useMemo(() => myLongAsyncFunction(args), [args])
//also returns a promise:
let myMemoizedResult = React.useMemo(() => (async () => await myLongAsyncFunction(args)), [args])
What is the correct way to wait on the result from an asynchronous function and memoize the result using React.useMemo? I've used regular promises with plain JS but still struggle with them in these types of situations.
I've tried other approaches such as memoize-one, but the issue seems to be that the this
context changes due to the way that React function components work break the memoization, which is why I'm trying to use React.useMemo.
Maybe I'm trying to fit a square peg in a round hole here - if that's the case it would be good to know that too. For now I'm probably just going to roll my own memoizing function.
Edit: I think part of it was that I was making a different silly mistake with memoize-one, but I'm still interested to know the answer here wrt React.memo.
Here's a snippet - the idea is not to use the memoized result directly in the render method, but rather as something to reference in an event-driven way i.e. on a Calculate button click.
export const MyComponent: React.FC = () => {
let [arg, setArg] = React.useState('100');
let [result, setResult] = React.useState('Not yet calculated');
//My hang up at the moment is that myExpensiveResultObject is
//Promise<T> rather than T
let myExpensiveResultObject = React.useMemo(
async () => await SomeLongRunningApi(arg),
[arg]
);
const getResult = () => {
setResult(myExpensiveResultObject.interestingProperty);
}
return (
<div>
<p>Get your result:</p>
<input value={arg} onChange={e => setArg(e.target.value)}></input>
<button onClick={getResult}>Calculate</button>
<p>{`Result is ${result}`}</p>
</div>);
}
What you really want is to re-render your component once the asynchronous call is over. Memoisation alone won't help you achieve that. Instead you should use React's state - it will keep the value your async call returned and it will allow you to trigger a re-render.
Furthermore, triggering an async call is a side effect, so it should not be performed during the render phase - neither inside the main body of the component function, nor inside useMemo(...)
which also happens during the render phase. Instead all side effects should be triggered inside useEffect
.
Here's the complete solution:
const [result, setResult] = useState()
useEffect(() => {
let active = true
load()
return () => { active = false }
async function load() {
setResult(undefined) // this is optional
const res = await someLongRunningApi(arg1, arg2)
if (!active) { return }
setResult(res)
}
}, [arg1, arg2])
Here we call the async function inside useEffect
. Note that you cannot make the whole callback inside useEffect
async - that's why instead we declare an async function load
inside and call it without awaiting.
The effect will re-run once one of the arg
s changes - this is what you want in most cases. So make sure to memoise arg
s if you re-calculate them on render. Doing setResult(undefined)
is optional - you might instead want to keep the previous result on the screen until you get the next result. Or you might do something like setLoading(true)
so the user knows what's going on.
Using active
flag is important. Without it you are exposing yourself to a race condition waiting to happen: the second async function call may finish before the first one finishes:
setResult()
happenssetResult()
happens again, overwriting
the correct result with a stale oneand your component ends up in an inconsistent state. We avoid that by using useEffect
's cleanup function to reset the active
flag:
active#1 = true
, start first callactive#1 = false
active#2 = true
, start second callsetResult()
happenssetResult()
doesn't happen since active#1
is false