React-Query Continuous Polling
When you’re waiting for new data, but a socket isn’t available

I’ve outfitted React-query for many different purposes over the last year. One unique problem I faced recently was how to handle the continuous polling of an endpoint, to check when some data was available and early-exit from re-polling once received.
More specifically, in the crypto ecosystem of Hedera, a mirror node provides a queryable REST API that returns important information. Notably, it provides account balance data (i.e. how many tokens you have in your wallet).
Whenever you conduct a transaction, your new account balance will not be reflected in the transaction data. You must retrieve it from the mirror node API, but the mirror node is slightly delayed from the ledger.
Normally, the lifecycle of the data would be sufficiently handled by the magic of React-query.
const useAccountBalanceQuery = <T = AccountBalanceData>(
accountId?: AccountId,
options?: UseQueryOptions<AccountBalanceData, Error, T>
): UseQueryResult<T, Error> => {
return useQuery<AccountBalanceData, Error, T>(
['accountBalance', accountId],
async () => {
return getAccountBalance(accountId)
},
{
...options,
}
)
}
If the mirror node API was 100% up-to-date with the Hedera ledger, post-transaction, you could simple invalidate this query to refetch and repopulate account balance data in the entire React app. However, in this case, this solution is too eager.
An incorrect but simple idea is to set a delay before invalidating this query.
//...transaction async code above, it completes.
setTimeout(() => queryClient.invalidateQuery(['accountBalance', accountId], 5000)
However, there is no guarantee that the data will return before, or after 5000 milliseconds. You end up having to wait potentially longer, or not long enough for the updated account balance data.
To solve this properly, you need to poll for data in an interval.
You could also consider calling refetch() on an interval, until the value returned is different than the original value. However, you lose granularity of the query and risk potential edge-cases (i.e. interference from invalidations triggered elsewhere besides the polling); the loading state is also not encapsulated.
The solution I came up with was to create a duplicate “polling” useQuery dedicated to manually checking the old account balance and asking the mirror node API every second to see if it has changed. A dedicated polling usePollingAccountBalanceQuery() hoists the logic into its own manageable lifecycle, away from the original query useAccountBalanceQuery().
const POLLING_TIMEOUT_SECONDS = 7
const sleep = (delay: number) =>
new Promise((resolve) => setTimeout(resolve, delay))
export const usePollingAccountBalanceQuery = <T = AccountBalanceData>(
accountId?: AccountId,
options?: UseQueryOptions<AccountBalanceData, Error, T>
): UseQueryResult<T, Error> => {
const { data: previousAccountBalanceData } =
useAccountBalanceQuery<AccountBalanceData>(accountId)
const previousAccountBalanceDataRef = useRef<AccountBalanceData | undefined>(
previousAccountBalanceData
)
previousAccountBalanceDataRef.current = previousAccountBalanceData
return useQuery<AccountBalanceData, Error, T>(
['pollingAccountBalance', accountId],
async () => {
//race between get account balance and polling timeout seconds
for (let i = 0; i < POLLING_TIMEOUT_SECONDS; i++) {
try {
//call the mirror node, but maybe the data is still stale there
const balance = await getAccountBalance(accountId)
//if it is not the same as the old data, we're good to go!
if (
balance !==
previousAccountBalanceDataRef.current
) {
return balance
}
} catch (error) {}
//sleep a second
await sleep(1000)
}
//after waiting 7 seconds, perhaps something went wrong so we just return the old balance
return previousAccountBalanceDataRef.current
},
{
enabled: false,
staleTime: Infinity,
...options,
}
)
}
It’s a react-query that is enabled = false, and staleTime = Infinity, so that it would only operate when called. It attempts a mirror node API call sleeping 1 second between calls, then compares the result with the old account balance data. Lastly, it will quit after 7 tries (~7 seconds) and return the old account balance data if it doesn’t see any new data come in.
Here’s how it would then be used, in conjunction with accountBalance data that lives globally.
const { refetch: fetchNewBalance } = usePollingAccountBalanceQuery(accountId)
const callTransaction = async (transactionData) = {
//...transaction async code above, it completes.
const newAccountBalance = await fetchNewBalance()
//manually update the global account balance now
queryClient.setQueryData(
['accountBalance', accountId],
newAccountBalance.data
)
}
Note that the update of accountBalance data could also be refactored into the onSuccess() callback of usePollingAccountBalanceQuery().
So the polling query serves as a worker for the global query. It will serve up accountBalance data as soon as it is available.
One of the critical advantages of this design is that it allows you to hook into the polling lifecycle, showing a spinner like in the image above when polling for the data.
const isWaitingForNewBalance = useIsFetching(['pollingAccountBalance', accountId])
return <BalanceLoading isLoadingBalance={isWaitingForNewBalance} />
Voila, a React-Query polling hack without sockets. See a demo here: https://www.saucerswap.finance/swap