Go back

How I fixed a UX issue with a Polling mechanism

We ran into a minor issue at Gitsecure last week. "What exactly was the issue?" You might ask me.

Well, here's what it entailed: When new users try to import their repositories from GitHub on the Gitsecure platform, it takes a little while for the repositories to show up on their dashboard, unless they do a "hard reload"

A potential fix

This is bad, as we can not always tell people to reload their dashboards to see their repos. It'll be sad and chaotic, however small the task (refreshing the tab) may seem.

Daniel noticed and reached out. He went on to suggest a delay. While that felt straightforward to implement, I knew so well from my travails with timeouts in JavaScript not to go that route. But, that was the idea I needed to get this to yield results.

Possible side-effects

Because we can't exactly pinpoint when a request is complete or successful, the approach above will be inefficient, and tiny inefficiencies are what we should try as much as possible to eliminate before they get out of hand.

The request to the server could have been completed and the user would still need to wait for the timeout to elapse before they have access to their data. Or in some cases, the user may be behind a poor internet connection, and we'll end up showing them a fallback UI stating that "we did not find any repositories..."

And, this again, isn't the best.

Enter polling.

A few weeks ago, I did something around the use of a sliding window for a refresh token auth flow. I took a little bit of inspiration from that pattern.

So my first attempt at solving this problem led me to the use of a while loop. I started by having two constants, MAX_RETRIES and RETRY_INTERVAL to denote the number of times I want to mutate the request if the data I need is not available and how frequently I want this to be executed.

pages/dashboard/installer.tsx
import { useSearchParams } from 'next/navigation'
import React from 'react'
import { useRepositories } from '@hooks/useRepositories'
// ...a couple of other imports
 
const MAX_RETRIES = 10
const RETRY_INTERVAL = 3000
 
export default function Installer() {
  const params = useSearchParams()
  const installationId = params.get('installation_id')
  const { mutate, repositories } = useRepositories(workspace as string, 1)
 
  const pollForRepositories = React.useCallback(async () => {
    let retries = 0
    while (retries < MAX_RETRIES) {
      await mutate()
 
      if (repositories?.results?.length > 0) {
        return true
      }
 
      await new Promise((resolve) => setTimeout(resolve, RETRY_INTERVAL))
      retries++
    }
 
    return false
  }, [fetchRepos, repositories])
 
  return <>// markup</>
}

At first, this felt like the best way to solve this, until I had it tested. Lo and behold! The mutate() call from my useRepositories hook was triggered repeatedly. It took me a little while to realize I was the cause of my problem.

So long as the the condition on line 16, highlighted in the snippet above is true, mutate() will be called and since there's no way to tell if the retries count is updated it'll continue in that loop.

Although, on line 24, I'm incrementing the variable's value, React isn't aware of this change, hence the inefficiency here. If you take another close look at the snippet, from lines 19 through 21, you'll see that I'm checking to see if the results array is not empty.

What I failed to mention from the get-go is that the request to the server was always successful, but the results array was empty on the initial render. Hence the check for its emptiness.

Relying on React's Lifecycle methods

Two things that affected the approach above include the absence of a component state for the amount of retries and the emptiness of the results array, in this case, a repositoryCount state variable should suffice. With this, I can make the component aware of the counts before mutating the request.

Once my condition is met, I redirect the user to their dashboard, with their repo data available for them to see. If not, we keep polling the request provided the MAX_RETRIES count isn't exceeded.

pages/dashboard/installer.tsx
import React from 'react'
import { useSearchParams } from 'next/navigation'
import { useToastContext } from '@hooks/toast'
import { useRepositories } from '@hooks/useRepositories'
 
const MAX_RETRIES = 10
const RETRY_INTERVAL = 3000
 
export default function Installer() {
  const params = useSearchParams()
  const router = useRouter()
  const installationId = params.get('installation_id')
  const { mutate, repositories } = useRepositories(workspace as string, 1)
 
  const [retryCount, setRetryCount] = React.useState<number>(0)
  const { openToast } = useToastContext()
 
  React.useEffect(() => {
    if (retryCount < MAX_RETRIES) {
      const timeoutId = setTimeout(async () => {
        await mutate()
 
        if (repositories?.results?.length > 0) {
          router.push('/dashboard')
        } else {
          setRetryCount((prevCount) => prevCount + 1)
        }
      }, RETRY_INTERVAL)
 
      return () => clearTimeout(timeoutId)
    } else if (retryCount >= MAX_RETRIES) {
      router.push('/dashboard')
      openToast('Repositories failed to load after multiple attempts', 'error')
    }
  }, [retryCount, repositories, mutate, router, openToast])
 
  return <>// markup</>
}

Now, In the snippet above, You'll see how the condition is retained. But, this time around, I used a state variable retryCount that is always updated in the else block on lines 25 through 27, and on line 30, I did a cleanup of the effect

This way, the mutation only happens if the retryCount is less than the Maximum retries allowed and the user is redirected to their dashboard when the data is available.

Wrapping up.

When you look closely, you'll see how the user is still redirected to their dashboard on lines 31 through 34. But this time around, I showed a toast component telling them that we couldn't load their repositories. This is better than having them wonder what exactly is going on.

If you already have an account on Gitsecure, you can see how this works by navigating to the integrations tab on your dashboard, uninstall and install our GitHub app, and let me know how it goes. My handle on Twitter is @kafLamed.