How To Better Poll APIs in React?

Alternative to setInterval, a better solution for calling async methods at intervals

Zachary Lee
Dev Genius

--

Photo by Agê Barros on Unsplash

In web development, we may need to constantly poll a backend API to get the latest data to update on the page. While WebSocket is a better choice, there are cases where polling is fine.

So how to do it in React?

setInterval

We can use setInterval to continuously execute the async method, which is probably the easiest solution.

const App = () => {
const [origin, setOrigin] = useState('');
const updateState = useCallback(async () => {
const response = await fetch('https://httpbin.org/get');
const data = await response.json();
setOrigin(data?.origin ?? '');
}, []);
useEffect(() => {
setInterval(updateState, 3000);
}, [updateState]);
return <main>{`Your origin is: ${origin}`}</main>;
};

But this solution has some problems. First of all setInterval is not accurate, for reasons you can check this article of mine:

Second, its granularity is not easy to control, and it causes waste. For example, for an API request with a long response time, the content of the last response has not been updated on the page, and the next request will be sent again.

This is not ideal.

setTimeout + async…await

We can use setTimeout and async...await to implement a custom hook:

const useIntervalAsync = (fn: () => Promise<unknown>, ms: number) => {
const timeout = useRef<number>();
const run = useCallback(async () => {
await fn();
timeout.current = window.setTimeout(run, ms);
}, [fn, ms]);
useEffect(() => {
run();
return () => {
window.clearTimeout(timeout.current);
};
}, [run]);
};

Next, it’s used like this:

const App = () => {
const [origin, setOrigin] = useState('');
const updateState = useCallback(async () => {
const response = await fetch('https://httpbin.org/get');
const data = await response.json();
setOrigin(data?.origin ?? '');
}, []);
useIntervalAsync(updateState, 3000); return <main>{`Your origin is: ${origin}`}</main>;
};

This solution uses async...await and setTimeout to ensure that the asynchronous task is completed before the next scheduled task. And clear the next scheduled task in the cleanup phase of useEffect.

But asynchronous tasks are always tricky. Imagine a case: if the component using this hook is unloaded while waiting for an asynchronous response, then this timed task will always run in the background. This is a serious bug, we can avoid this by logging the mount status.

const useIntervalAsync = (fn: () => Promise<unknown>, ms: number) => {
const timeout = useRef<number>();
const mountedRef = useRef(false);
const run = useCallback(async () => {
await fn();
if (mountedRef.current) {
timeout.current = window.setTimeout(run, ms);
}
}, [fn, ms]);
useEffect(() => {
mountedRef.current = true;
run();
return () => {
mountedRef.current = false;
window.clearTimeout(timeout.current);
};
}, [run]);
};

This bug can be avoided by recording the mount status of mountedRef. It's simple, right, but it's actually very practical, of course, you can also make the mounted state a custom hook for reuse. For example useMountedState below:

import { useCallback, useEffect, useRef } from 'react';const useMountedState = () => {
const mountedRef = useRef(false);
const getState = useCallback(() => mountedRef.current, []);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
return getState;
};
export default useMountedState;

Complex case

In some more complex cases, we may need to actively update page information. For example, after some interaction, I expect to call updateState immediately to update the latest data on the page.

Of course, I can call updateState directly, but the next scheduled task may be executed very quickly, which is a waste. So we can add features to useIntervalAsync to make it support flushing.

You can see that we have added the flush method. Its internal logic is to cancel the next scheduled task and execute the run method directly.

But we added a runningCount, what is this for?

Imagine a case: when the hook internally executes the run function at normal logical intervals, while the asynchronous response is waiting to be resolved, flush is called externally to expect immediate execution, and then run will be executed again. This is because the last run has not been resolved, and the latest scheduled task has not been created, so it cannot be canceled.

That is to say, there are two run functions being executed at this time, although this is not critical, but if we still use the previous logic, then these two run will create two timed tasks after they are resolved. This leads to even greater waste.

So we can use runningCount to record the current number of executions, and in the next function to ensure that a new scheduled task is created only when runningCount is 0.

In addition, through TypeScript’s generics, we can easily wrap the original function to accommodate more cases. A simple example:

const App = () => {
const [origin, setOrigin] = useState('');
const updateState = useCallback(async () => {
const response = await fetch('https://httpbin.org/get');
const data = await response.json();
setOrigin(data?.origin ?? '');
}, []);
const update = useIntervalAsync(updateState, 3000); return (
<main>
<div>{`Your origin is: ${origin}`}</div>
<button onClick={update}>update</button>
</main>
);
};

Conclusion

Handling asynchronous tasks is always tricky and we may need to be careful not to make mistakes. Hope this article is helpful to you.

If you have any thoughts on today’s content, please leave your comments.

Thanks for reading. If you like such stories and want to support me, please consider becoming a Medium member. It costs $5 per month and gives you unlimited access to Medium content. I’ll get a little commission if you sign up via my link.

Your support is very important to me — thank you.

--

--