Why You Should Use React Query or SWR for External Data
Most of us out here in React-land are out building our apps with the shiny new Hooks API and slinging requests to external APIs like nobody's business. Those of us new to hooks may have started creating hooks that look like this simplified example:
export const useSearch = () => {
const [query, setQuery] = useState();
const [results, setResults] = useState();
useEffect(() => {
if (!query)
return;
api.search(query)
.then(results => setResults(results));
}, [query]);
return {
query,
setQuery,
results
}
}
The Problem
However, each time a hook is called, that instance is unique to the component it was called in, so there's a few issues we'll run into:
- Updating the above
query
in one instance won't update it for the other instances. - If we have three components using a hook that makes an API request, we will get at least one request per component.
- If we have multiple requests in the air, and we try to store them in a global store or have the hook hold the state, we will end up with state out of sync or subsequent requests overwriting each other.
One way to solve this is to leave the request out of hooks and only call it on mount in a component you can guarantee will be singular (aka a singleton component, like a page/route perhaps). Depending on how that data is used, this can sometimes be very tricky to do.
Potential Solutions
So what can we do? There are a few options:
- Make sure the data returned from the API goes in context or some kind of global state management, accepting the multiple requests (potentially overexerting our server's API)
- Doing the above + use a library like react-singleton-hook, ensure there's only a single component with the
useEffect
doing the API call, or similar to prevent multiple requests. - Implement some kind of way to cache the data (while still being able to invalidate that as necessary) so that we pull from the cache first
- Use React Query or SWR
The Real Solution
The real solution here is to use option 4. Both libraries have done really sophisticated work to solve these problems to prevent you from having to do it yourself. Caching is extremely tricky to get right and can have pretty dire consequences if done poorly, leading to your app partially or completely breaking.
Other Problems They Solve
Here are a few examples of other problems these libraries can solve with code examples for each. Code examples are using React Query but are nearly the same with SWR.
Window Focus Refetching
One big issue that we encounter with Javascript heavy sites and apps is that a user may be on one tab/window messing with data and then switch to another of the same app. The problem here is that if we aren't keeping our data fresh, these can fall out of sync. Both libraries solve this by refetching data once the window has focus again. If you don't need that or can't have that behavior, you can simply disable as an option.
const { data: syncedData } = useQuery(id, id => getSyncedData(id), {
refetchOnWindowFocus: true /* this actually doesn't need to be specified because it is on by default */
})
Request Retry, Revalidation and Polling
Sometimes requests error temporarily, it happens. Both libraries answer this problem by allowing configured automatic retries, so anytime they encounter an error, it will retry the specified amount of times until finally throwing an error. Additionally, you can use either to poll an endpoint constantly by just setting their refetch/refresh interval to a number in milliseconds.
Retry Example
const { data: books } = useQuery(id, id => getBooks(id), {
retry: 5, //retry 5 times before erroring
retryDelay: 1000 //retry every second
})
/* default values: {
retry: 3,
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000)
}*/
Polling Example
const indexing = useRef(false);
const { data: searchResults } = useQuery(['search', keyword], (key, keyword) => search(keyword), {
//poll every second until finished indexing
refetchInterval: indexing.current ? 1000 : undefined,
//invalidate query cache after each query is successful,
//until we're no longer indexing
onSuccess: async data => {
if (data.indexing) {
await queryCache.invalidateQueries(keyword)
}
}
});
//update our indexing ref
indexing.current = !!searchResults?.indexing;
Mutation with Optimistic Updates
Let's say you have a list of users and you want to update the information of one of those users, a pretty standard operation. Most people are pretty content with seeing a loader to indicate the server is working to update that user, and waiting for it to finish before seeing the updated list of users.
However, if we know what the updated list of users will look like locally (because the user just made the update themselves), do we really even need to show a loader? No, both libraries allow you to do mutations on your cached data that will update locally immediately and start the update to the server in the background. They will also make sure that data gets refetched/validated to be the same once it returns from the server, and if not, the returned data will fall into place.
Imagine we have a page where the user gets to edit their information. First, we need to fetch that information from the backend.
const cache = useQueryCache()
const userCacheKey = ['user', id];
const { data: user } = useQuery(userCacheKey, (key, id) => {
return fetch(`/user/${id}`).then(res => res.json());
});
Next we need to setup a function to allow us to update the user's data once they submit the form.
const [updateUser] = useMutation(
newUser => fetch(`/user/${id}`, {
method: 'POST',
body: JSON.stringify(newUser)
}).then(res => res.json()),
...
If we want to have our local data update the UI optimistically, we have to add some options to the mutation. The onMutate
here will shove the data locally into the cache firing before the actual update so our UI won't show a loader. The return value is used in case of an error, and we need to reset to our previous state.
onMutate: newUser => {
cache.cancelQueries(userCacheKey)
const oldData = cache.getQueryData(userCacheKey)
cache.setQueryData(userCacheKey, newUser)
return oldData
}
If we're updating optimistically, we need to be able to handle errors and also make sure that the server returns the same data. So we add two more hooks into our mutation's options. onError
will use the data returned from onMutate
so that we can reset to the previous state. onSettled
makes sure we refetch the same data from the server so that we don't end up out of sync.
//reset with previous data on error
onError: oldUser => {
cache.setQueryData(userCacheKey, oldUser)
},
//make sure we refetch data from the server itself
onSettled: () => {
cache.invalidateQueries(userCacheKey)
}
Prefetching and Background Fetching
If you have an idea about some data that the user is about to need, you can use these libraries to prefetch that data. By the time the user gets to it, the data is already loaded making the transition instant. This can really make your apps feel snappier.
const prefetchUpcomingStep = async (stepId) => {
await cache.prefetchQuery(stepId, stepId => fetch(`/step/${stepId}`))
}
//later on...
prefetchUpcomingStep('step-137')
//this will grab the data ahead of time before we actually get to that step's query
On a similar note, if the user already has received some data, but it's time to refresh, the libraries will fetch new data behind the scenes and replace the old data if and only if that data is different. This prevents showing the user a loading indicator, only notifying them if there's something new which is a much better user experience.
Imagine we have a Twitter-style feed component that constantly is refetching for new posts
const Feed = () => {
const { data: feed, isLoading, isFetching } = useQuery(id, id => getFeed(id), {
refetchInterval: 15000
});
...
We can notify users that the data is upgrading in the background by listening for isFetching
to be true
which will fire even if cache data is present.
<header>
<h1>Your feed</h1>
{
isFetching &&
<Notification>
<Spinner /> loading new posts
</Notification>
}
</header>
If we have no data in the cache at all and the query is fetching data, we can listen for isLoading
to be true
and show some kind of loading indicator. Finally, if isSuccess
is true
and we received data, we can display the posts themselves.
<FeedContainer>
{
isLoading && <LoadingCard />
}
{
feed && isSuccess && feed.posts.map(post => (
<Post {...post} />
))
}
</FeedContainer>
Feature Comparison
React Query's creator did a great job building out a feature table comparison for React Query, SWR, and Apollo for you to see what features are available. One big feature that I'd like to call out that React Query has over SWR is it's own set of dev tools which are really helpful for debugging misbehaving queries.
Conclusion
Over my time as a developer, I've tried to solve these problems myself, and had I had a library like React Query or SWR, I would have saved a ton of time. These problems can be really tricky to solve and the solutions can inject subtle bugs into your app that are difficult or time-consuming to debug. Luckily, we have open source and these people were generous to offer their own robust solutions for us to use.
If you'd like to see more about the problems these libraries solve and the efforts needed to solve them, Tanner Linsley did a great walkthrough of what he was experiencing and how he solved it. You can watch that walkthrough here:
Overall I think these libraries are great additions to the ecosystem and will help us all write better software. I'd like to see other frameworks come out with similar libraries, because the concepts here are pretty universal. I hope you found this helpful and let us know any unique strategies you have when using these libraries!
PS. What about GraphQL? š
Well, a lot of the GraphQL libraries out there actually built in a lot of these concepts from the get go, so if you're using something like Apollo or Urql, you probably are already getting these benefits. However, both libraries are compatible with anything that returns a promise, so if your particular favorite GQL library doesn't have these features, try putting React Query or SWR in front of it. š