Designing APIs for library interfaces is a pretty hard thing to do. You want your APIs to be flexible and cater to all needs. At the same time, you'd want them to be minimal and intuitive to avoid a steep learning curve. Those things are two sides of the same coin, and you'd likely want to balance somewhere in the middle. Good defaults matter a lot, too.
What's also hard is "getting it right" on the first try, because you cannot easily undo those decisions. Bugs can be fixed. APIs can be widened and features can be added if needed. Changing an existing API, or taking one away is a totally different beast. It needs a major version bump.
Major changes
No one likes breaking changes, and it's hard to get people excited about major version bumps. Unless there is a fundamental architectural change involved that enables us to ship new features in them (like the networkMode in React Query v4), major versions aren't about new features.
React shipped hooks in v16.8. React Router shipped loaders in v6.4. Those were all minor versions.
So if major versions aren't about new features, what are they about? Will McGugan said it best:
Ouch, that hurts, but it's on point. More often than not, I've used major version bumps to revert design-decision that we've taken in the past, because they've turned out to be suboptimal. When designing an API, you can't always think everything through. Edge cases will pop up that you haven't even though about. Things might not work as expected, and you realize that changing the API is the best way forward.
React Query v5
So React Query is nearing its 5th API fail, and we've taken a quite controversial decision, which I announced yesterday: We're going to remove the callbacks on useQuery
:
This almost got me my first twitter 💩-storm, which was to be somewhat expected. Why would you take something away from me? Let me write code the way I want to, this is a terrible idea!
Before you jump on the bandwagon, please note that we're talking only about the callbacks of useQuery
, NOT about the useMutation
callbacks. Once I explained this and the drawbacks about those callbacks, most people tend to agree with that decision.
A bad API
Let's quickly recap which API we're actually talking about. useQuery
has three callback functions: onSuccess
, onError
and onSettled
, which will be called when the Query ran either successfully or had an error (or in both cases). You can (apparently) use them to execute side effects, like showing error notifications:
export function useTodos() {
return useQuery({
queryKey: ['todos', 'list'],
queryFn: fetchTodos,
onError: (error) => {
toast.error(error.message)
},
})
}
Users like this API because it's intuitive. Without those callbacks, you would seemingly need the dreaded useEffect
hook to perform such a side effect:
export function useTodos() {
const query = useQuery({
queryKey: ['todos', 'list'],
queryFn: fetchTodos,
})
React.useEffect(() => {
if (query.error) {
toast.error(query.error.message)
}
}, [query.error])
return query
}
Many users see it as a huge selling point of React Query that they don't need to write useEffect
anymore. In this example, the effect clearly shows the drawback of this approach: If we call useTodos()
two times in our App, we will get two error notifications!
This is pretty obvious if you look at the useEffect
implementation - both components will call the custom hook, both components will register an effect, which will then run inside that component. It's not so obvious with the onError
callback. You might expect those calls to be deduplicated - but that is not happening. The callbacks run purposefully for each component, because the callbacks could close over local state values. It would be quite inconsistent to call it for one component, but not for another. We also can't decide for which component it should run.
But no, we are not leaving you to write useEffect
for this scenario. As I just pointed out, the effect is equally flawed. There is a callback that is only invoked once per Query, and I've written about that solution before in - #11: React Query Error Handling.
The best way to address this problem is to use the global cache-level callbacks when setting up your QueryClient
:
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => toast.error(`Something went wrong: ${error.message}`),
}),
})
An additional render cycle
setTodoCount
introduces another render cycle. This will not only make your App render more often than necessary (which might or might not matter), but it also means that the intermediate render cycle will contain false values.
As an example, suppose fetchTodos
returns a list of length 5. With the above code, there will be three render cycles:
todos
will beundefined
and length will be 0. That's the initial state while the Query is fetching. This is correct.todos
will be an Array of length 5 andtodoCount
will be 0. That's the in-between render cycle whereuseQuery
has already run (and so hasonSuccess
), butsetTodoCount
was scheduled. This is wrong because values are not in-sync.todos
will be an Array of length 5 andtodoCount
will be 5. That is the final state and is correct again.
In Summary
The only good use-case that came out of the twitter discussion was scrolling a feed to the bottom when a new chat message arrived. That is a good example, similar to animating an item if a fetch failed, because you'd want that to be different on a per-component level. Still, those cases are the minority by far, and if that's what you want to do, you can achieve the same thing with useEffect
. data
and error
returned from useQuery
are referentially stable, so you can set up an effect pretty easily without having to worry about it running too often.
APIs need to be simple, intuitive and consistent. The callbacks on useQuery
look like they fit these criteria, but they are bug-producers in disguise. It's pretty bad because they will likely do what you want when you first implement them, but they have a toll when you refactor or extend your App as it grows. They also invite antipatterns because you can introduce error-prone state-syncing without feeling bad while doing so.