Async React Components
How to handle async logic with react
Recently I saw a pattern in react codebase of:
import React from 'react';
const count = 0;
function getNextNumber() {
return count++;
}
async function doAsync() {
/**
* Some async operation
* most of the time it's data fetching or timers
*/
return new Promise(resolve => resolve(getNextNumber()), Math.random() * 10000);
}
export function AsyncReactComponent() {
const [data, setData] = React.useState<number | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
async function handleClick() {
setIsLoading(true);
setError(null);
try {
const data = await doAsync();
setData(data);
setIsLoading(false);
setError(null);
} catch (e) {
setData(null);
setIsLoading(false);
setError(e);
}
}
return (
<div>
<div>{data}</div>
{isLoading ? <div>Loading...</div> : null}
{error != null ? <div>{error}</div> : null}
<button onClick={handleClick}>Do async</button>
</div>
);
}
When clicking on the button, async function get called. We are waiting to the promise to resolve and after that we change the state of the component. Although from this example the code may look simple, it has some problems.
- Calling setState on an unmounted component Sometimes the promise will be resolved after the component unmount (same as “Sometimes the component will unmount before the promise will be resolved). And calling some setState will show this error in the console You can see it yourself.
Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.
You can still hack it with useIsMounted
hook (I saw some codebases that inlined it inside the component).
export function useIsMounted() {
const isMountedRef = React.useRef(true);
React.useEffect(() => {
return () => (isMountedRef.current = false);
});
return () => {
return isMountedRef.current;
};
}
this will just give us a function to check if the component is mounted. So your code will look like:
const isMounted = useIsMounted();
async function handleClick() {
setIsLoading(true);
setError(null);
try {
const data = await doAsync();
if (!isMounted()) {
return;
}
setData(data);
setIsLoading(false);
setError(null);
} catch (e) {
if (!isMounted()) {
return;
}
setData(null);
setIsLoading(false);
setError(e);
}
}
And the error won’t happen.
But it leads to bugs, beacuse you have to handle async code inside you react component and you have to be careful every time when you are calling setState
after async function.
- Race Conditions
Take the same example of
<AsyncReactComponent/>
. Imagine the user clicks on the button and callingdoAsync
multiple times.doAsync
DOES NOT guarantee to fulfil / reject the in the same order as the call order (its a real word scenario for examples in fetch). In this case, the data the user will show will be the “outdated” data of 1 instead of 2, because we are setting the data only after doAsync fulfills and override it if doAsync fulfilled slowly even if it’s started before. You can fix it if you ignore the promise (promise is not cancellable) when new async request calls. For further reading about race conditions you can look here.
So what do you offer?
Seperate async logic from your react ui component.
- Context. Instead of using the async logic inside your component, put it inside a context provider. Your component will just consume the data, and wont have to mix the async logic inside. Also, if more than one component will want to subscribe to this data, you can reuse this context (in contrast to async-await inside you react component).
const { isLoading, data, error } = useAsyncData();
and you context will look like:
const AsyncDataContext = React.createContext(null);
const [data, setData] = React.useState(null);
const callDoAsync = React.useCallback(async () => {
const data = await doAsync();
setData(data);
}, [doAsync]);
const value = React.memo({ data, callDoAsync }, [data, callDoAsync]);
function AsyncDataProvider(props: { children: React.ReactNode }) {
const { children } = props;
return <AsyncDataContext.Provider value={value}>{children}</AsyncDataContext.Provider>;
}
const useAsyncData = React.useContext(AsyncDataContext);
function MyReactComponent() {
const { data, callDoAsync } = useAsyncData();
}
function App() {
<AsyncDataProvider>
<MyReactComponent />
<OtherComponentThatCaresAboutTheData />
</AsyncDataProvider>;
}
currently storing complex data (object) for context value lead to re-render of the context for every change untill we will have context selectors. But the good part us that the context is scoped inside the tree, and the rerender does not cost too much (for me, for now)
- Redux - do the async stuff in the store (redux thunk / redux saga [choose redux thunk])
- MobX - you know what to do Manage it inside your state management solution.
React-Query
I recommend to use react-query
for data fetching. It gives me the save interface as before inside your component (and it also have a good caching).
Subscribe to Nir Tamir
Get the latest posts delivered right to your inbox