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:
- Complete network failure during the
fetch(...)
- Server being (temporarily) down
- Not authorized
- Backend URL not found
- 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