The original tkdodo. Eu/blog/practi…
When GraphQL and especially the Apollo Client came out in 2018, there was a lot of talk that it would completely replace Redux. The question was “Is Redux dead?” I was asked a lot.
I didn’t understand what was going on. Why do some data acquisition libraries replace our global state manager? What does that have to do with anything?
My impression is that GraphQL clients like Apollo will only get the data for you, similar to axios for making REST requests, and you obviously still need some way to make that data accessible to your application. Actually, I was wrong.
Client status and server status
Apollo gives you more than just the ability to describe the data you want and get that data, it also provides a cache for that server data. This means that you can use the same (with the same key) useQuery hook in multiple components, which will fetch the data only once and then return it from the cache.
This is very similar to what we use Redux for, and many teams use Redux for: taking data from the server and making it available everywhere.
As a result, we seem to have been treating server state like any other client state. Your application does not own server state (think: list of articles you get, user details you want to display……). . We just borrowed it to display the latest version of it on the screen for the user. The server owns the data.
To me, this introduces a paradigm shift in how you think about data. If we can leverage caching to display data we don’t own, there really isn’t much client-side state that we need to make available to the entire application. That makes me understand why a lot of people think Apollo can replace Redux in a lot of situations.
React Query
I’ve never had a chance to use GraphQL. We have an existing REST API that doesn’t really run into over-fetching issues and is currently available. Obviously, we don’t have enough pain points to switch GraphQL, especially considering that you also have to adapt backend modifications, which isn’t that simple.
However, I still envy the simplicity of front-end data acquisition, including loading and error state handling. If only there was something like this in the React REST APIs…
Find the React Query.
React Query, developed by open source Tanner Linsley in late 2019, takes the benefits of Apollo and brings them to REST. It works for any function that returns a Promise and uses a stale-while-revalidate cache strategy. Running with reasonable defaults, the library tries to make your data as fresh as possible, while showing it to users as early as possible, making it feel almost instantaneous at times and providing a great user experience. Best of all, it’s also very flexible, allowing you to customize various Settings when the defaults don’t work.
However, this article will not cover React Query.
I think the documentation is perfect for explaining the guidelines and concepts, you can watch videos from various talks, and if you want to familiarize yourself with the library, take Tanner’s React Query Essentials course.
I’d like to focus more on some practical tips outside of documentation that might be useful when you’re already using the library. These are some of the things I’ve learned over the past few months, as I’ve been actively using the library not only at work, but also participating in the React Query community and answering questions in Discord and GitHub discussions.
The default configuration
I’m sure React Query Defaults are great choices, but they can sometimes catch you off guard, especially at first.
First: React Query will not call queryFn every time it rerenders, even if the default staleTime is zero. Your application can be re-rendered at any time for any reason, so every fetch will be crazy!
Always write code for re-rendering, a lot of it. I like to call it render elasticity. “– Tanner Linsley
If you see a refetch that you didn’t expect, it’s probably because you just focused the window and React Query is doing a refetchOnWindowFocus, which is a great feature: If the user goes to a different browser TAB and then comes back to your application, this will automatically trigger a background reload, and if something on the server changes in the meantime, the data on the screen will be updated. All this happens without showing the Loading state, and if the data is the same as what you currently have in the cache, your component will not be re-rendered.
This can be triggered more frequently during development, especially since the focus between browser DevTools and your application also results in fetching, so be aware of this.
Second, there seems to be a bit of confusion between cacheTime and staleTime, so let me try to clarify:
- StaleTime: Duration of query conversion from fresh to stale. As long as the query is fresh, the data will always only be read from the cache — no network request will occur! If the query is out of date (default: immediate), you will still fetch the data from the cache, but background recapture may occur in some cases.
- CacheTime: Duration of removing an inactive query from the cache. This defaults to 5 minutes. Once no observer is registered, the query transitions to an inactive state, so when all components using the query have been unloaded.
In most cases, if you want to change one of these Settings, it is staleTime that needs to be adjusted. I rarely need to tamper with cacheTime. Examples in the documentation are also well explained.
useReact Query DevTools
This will greatly help you understand the state of the query. DevTools will also tell you what data is currently in the cache, so you can debug more easily. On top of that, I’ve found that if you want to better recognize background recapture, it helps to limit your network connection in the browser DevTools, because development servers are usually very fast.
Treat the query key as a dependent array
I’m referring here to the useEffect hook dependency array, which I assume you’re familiar with.
Why are these two similar?
Because React Query triggers a re-fetch every time the Query key changes. Therefore, when we pass a mutable parameter to queryFn, we almost always want to get the data when the value changes. Instead of writing complex logic to manually trigger recapture, we can use the query key:
// feature/todos/queries.ts
type State = 'all' | 'open' | 'done'
type Todo = {
id: number
state: State
}
type Todos = ReadonlyArray<Todo>
const fetchTodos = async (state: State): Promise<Todos> => {
const response = await axios.get(`todos/${state}`)
return response.data
}
export const useTodosQuery = (state: State) = >
useQuery(['todos', state], () = > fetchTodos(state))
Copy the code
In this case, assume that our UI displays a to-do list with a filter option. We’ll have some local state to store the filtering, and once the user changes their options, we’ll update that local state, and React Query will automatically trigger refetch for us because the Query key (state above) has changed. Therefore, we synchronize the user’s filter selection with the query function, much like useEffect relies on the array representation. I’ve never passed a key variable to queryFn that doesn’t belong to queryFn.
A new cache entry
Because the query key is used as the cache key, when you switch from ‘all’ to ‘done’ you will get a new cache entry, which will result in a hard load state (possibly showing the load spinner). This is certainly not ideal, so you can use the keepPreviousData option in these cases, or, if possible, use initialData to pre-populate newly created cache entries. The example above is perfect for this because we can do some client-side pre-filtering on our to-do list:
// pre-filtering
type State = 'all' | 'open' | 'done'
type Todo = {
id: number
state: State
}
type Todos = ReadonlyArray<Todo>
const fetchTodos = async (state: State): Promise<Todos> => {
const response = await axios.get(`todos/${state}`)
return response.data
}
export const useTodosQuery = (state: State) = >
useQuery(['todos', state], () = > fetchTodos(state), {
initialData: () = > {
const allTodos = queryCache.getQuery<Todos>(['todos'.'all'])
constfilteredData = allTodos? .filter((todo) = > todo.state === state) ?? []
return filteredData.length > 0 ? filteredData : undefined}})Copy the code
Now, every time a user switches between states, if we don’t already have data, we try to pre-populate it with data from the All To-do cache. We can immediately show the user the “done” to-do list, and once the background fetching is complete, they will still see the updated list. Note that prior to v3, you also needed to set the initialStale property to actually trigger the background extraction.
I think these lines of code are a nice user experience improvement.
Keep the server and client states separate
This is closely related to the put-functions-to-use-state, which I wrote last month: If you get data from useQuery, try not to put that data into local state. The main reason is that you implicitly opt out of all background updates that React Query does for you, because the state “copy” doesn’t update with it.
This is fine if you want, such as getting some default values for the form and rendering your form after getting the data. Background updates are unlikely to produce anything new, even if your form is already initialized. So if you’re doing this on purpose, make sure you don’t set staleTime to trigger unnecessary background recapitulation:
Initial form data
// initial-form-data
const App = () = > {
const { data } = useQuery('key', queryFn, { staleTime: Infinity })
return data ? <MyForm initialData={data} /> : null
}
const MyForm = ({ initialData} ) = > {
const [data, setData] = React.useState(initialData)
...
}
Copy the code
This concept can be a bit difficult to follow when displaying data that you also want to allow users to edit, but it has many advantages. I prepared a small code and examples: codesandbox. IO/s/separate -…
The important part of this demo is that we never put values from the React Query into the local state. This ensures that we always see the latest data, since there is no local “copy” of it.
The enable option is very powerful
The useQuery hook has many options that you can pass in to customize its behavior, and the Enabled option is a very powerful option that lets you do a lot of cool things. Here’s a short list of things we can do with this option:
- The related queries
Get data in one query and run the second query only after we successfully get data from the first query.
- Turn queries on and off
Because of refetchInterval, we have a query that polls the data periodically, but if Modal is turned on, we can temporarily suspend it to prevent the current update.
- Waiting for user input
There are some filtering criteria in the query key, but disable it as long as the user does not apply their filter.
- Disables queries after some user input
For example, if we have a draft value that should take precedence over the server data. See the example above.
Do not use queryCache as a local state manager
Never tamper with queryCache (queryCache.setData), it should only be used for optimistic updates or for writing data you received from the back end after mutation. Keep in mind that each background recapture may overwrite this data, so use something else as the local state.
Create custom hooks
Even if it’s just to wrap a useQuery call, creating a custom hook is usually rewarding because:
- You can keep the actual data retrieved from the UI, but with your
useQuery
The calls are in the same place. - You can store all usages of a query key (and possibly type definitions) in one file.
- If you need to adjust some Settings or add some data transformations, you can do it in one place.
You’ve seen an example of this in the todos query above.
I hope these practical tips will help you get started with React Query, so give it a try.