What @tanstack/react-query is is a fancy way of fetching data, on the client, in a React app.

Simplified primer by example; instead of...


function MyComponent() {
  const [userInfo, setUserInfo] = useState(null)
  useEffect(() => {
    fetch('/api/user/info')
    .then(response => response.json())
    .then(data => {
      setUserInfo(data)
    })
  }, [])

  return <div>Username: {userInfo ? userInfo.user_name : <em>not yet known</em>}</div>
}

you now do this:


import { useQuery } from '@tanstack/react-query'

function MyComponent() {
  const {data} = useQuery({
    queryKey: ['userinfo'],
    queryFn: async () {
      const response = await fetch('/api/user/info')
      return response.json()
    }
  })

  return <div>Username: {data ? data.user_name : <em>not yet known</em>}</div>
}

That's a decent start, but...

Error handling is a thing. Several things can go wrong:

  1. Complete network failure during the fetch(...)
  2. Server being (temporarily) down
  3. Not authorized
  4. Backend URL not found
  5. Backend URL found but wrong parameters

None of the code solutions above deal with these things. At least not all of them.

By default, useQuery will retry if any error thrown inside that queryFn call.

Queries that fail are silently retried 3 times, with exponential backoff delay before capturing and displaying an error to the UI.

From the documentation about important defaults

For example, if the server responds with a 403 the response body might not be of content-type JSON. So that response.json() might fail and throw and then useQuery will retry. You might be tempted to do this:


    queryFn: async () {
      const response = await fetch("/api/user/info")
+     if (!response.ok) {
+        throw new Error(`Fetching data failed with a ${response.status} from the server`)
+     }
      return response.json()
    }

The problem with this is that useQuery still thinks it's an error and that it should retry. Sometimes it's the right thing to do, sometimes pointless.

About retries

The default implementation in @tanstack/react-query can be seen here: packages/query-core/src/retryer.ts

In a gross simplification, it works like this:


function run() {
  const promise = config.fn()
  Promise.resolve(promise)
  .then(resolve)
  .catch((error) => {

    if (shouldRetry(config)) {
      await sleep(config.sleepTime())
      run()
    } else {
      reject(error)
    }

  })

I'm not being accurate here but the point is that it's quite simple. The config has stuff like a count of how many times it's retried previously, dynamically whether it should retry, and how long it should sleep.
The point is that it doesn't care what the nature of the error was. It doesn't test if the error was of type Response or if error.message === "ECONNRESET" or something like that.

So in a sense, it's a "dumping ground" for any error thrown. So if you look into the response, within your query function, and don't like the response, if you throw a new error, it will retry. And that might not be smart.

In simple terms; you should retry if retrying is likely to yield a different result. For example, if the server responded with a 503 Service Unavailable it's quite possible that if you just try again, a little later, it'll work.

What is wrong is if you get something like a 400 Bad Request response. Then, trying again won't work.
Another thing that is wrong is if your own code throws an error within. For example, ...


    queryFn: async () {
      const response = await fetch('/api/user/info')
      const userInfo = response.json()
      await doSomethingComplexThatMightFail(userInfo)
      return userInfo
    }

So, what's the harm?

Suppose that you have something basic like this:


    queryFn: async () {
      const response = await fetch("/api/user/info")
      if (!response.ok) {
        throw new Error(`Fetching data failed with a ${response.status} from the server`)
      }
      return response.json()
    }

and you use it like this:


function MyComponent() {
  const {data, error} = useQuery(...)

  if (error) {
    return <div>An error happened. Reload the page mayhaps?</div>
  }
  if (!data) {
    return <div>Loading...</div>
  }
  return <AboutUser info={data.userInfo}/>
}

then, I guess if it's fine to not be particularly "refined" about the error itself. It failed, refreshing the page might just work.

If not an error, then what?

The pattern I prefer, is to, if there is a problem with the response, to return it keyed as an error. Let's use TypeScript this time:


// THIS IS THE NAIVE APPROACH

type ServerResponse = {
  user: {
    first_name: string
    last_name: string
  }
}

...

function MyComponent() {
  const {data, error, isPending} = useQuery({
    queryKey: ['userinfo'],
    queryFn: async () {
      const response = await fetch('/api/user/info')
      if (!response.ok) {
         throw new Error(`Bad response ${response.status}`)
      }
      const user = await response.json()
      return user
    }
  })

  return <div>Username: {userInfo ? userInfo.user_name : <em>not yet known</em>}</div>
}

A better approach is to allow queryFn to return what it would 99% of the time, but also return an error, like this:


// THIS IS THE MORE REFINED APPROACH

type ServerResponse = {
  user?: {
    first_name: string
    last_name: string
  }
  errorCode?: number
}

...

function MyComponent() {
  const {data, error, isPending} = useQuery({
    queryKey: ['userinfo'],
    queryFn: async () {
      const response = await fetch('/api/user/info')
      if (response.status >= 500) {
         // This will trigger useQuery to retry
         throw new Error(`Bad response ${response.status}`)
      }
      if (response.status >= 400) {
         return {errorCode: response.status}
      }
      const user = await response.json()
      return {user}
    }
  })


  if (errorCode) {
     if (errorCode === 403) {
        return <p>You're not authorized. <a href="/login">Log in here</a></p>
     }
     throw new Error(`Unexpected response from the API (${errorCode})`)
  }
  return <div>
     Username: {userInfo ? userInfo.user_name : <em>not yet known</em>}
  </div>
}

It's just an example, but the point is; that you treat "problems" as valid results. That way you avoid throwing errors inside the query function, which will trigger nice retries.
And in this example, it can potentially throw an error in the rendering phase, outside the hook, which means it needs your attention (and does not deserve a retry)

What's counter-intuitive about this is that your backend probably doesn't return the error optionally with the data. Your backend probably looks like this:


# Example, Python, backend JSON endpoint 

def user_info_view(request):
    return JsonResponse({
        "first_name": request.user.first, 
        "last_name": request.user.last
    })

So, if that's how the backend responds, it'd be tempting to model the data fetched to that exact shape, but as per my example, you re-wrap it under a new key.

Conclusion

The shape of the data ultimately coming from within a useQuery function doesn't have to map one-to-one to how the server sends it. The advantage is that what you get back into the rendering process of your component is that there's a chance of capturing other types of errors that aren't retriable.

Comments

Your email will never ever be published.

Related posts