Skip to content
TkDodo's blog
TwitterGithub

Placeholder and Initial Data in React Query

ReactJs, React Query, JavaScript, TypeScript4 min read

placeholder and initial data
  • 한국어
  • Español
  • Add translation

Today's article is all about improving the user experience when working with React Query. Most of the time, we (and our users) dislike pesky loading spinners. They are a necessity sometimes, but we still want to avoid them if possible.

React Query already gives us the tools to get rid of them in many situations. We get stale data from the cache while background updates are happening, we can prefetch data if we know that we need it later, and we can even keep previous data when our query keys change to avoid those hard loading states.

Another way is to synchronously pre-fill the cache with data that we think will potentially be right for our use-case, and for that, React Query offers two different yet similar approaches: Placeholder Data and Initial Data.

Let's start with what they both have in common before exploring their differences and the situations where one might be better suited than the other.

Similarities

As already hinted, they both provide a way to pre-fill the cache with data that we have synchronously available. It further means that if either one of these is supplied, our query will not be in loading state, but will go directly to success state. Also, they can both be either a value or a function that returns a value, for those times when computing that value is expensive:

success-queries
1function Component() {
2 // ✅ status will be success even if we have not yet fetched data
3 const { data, status } = useQuery({
4 queryKey: ['number'],
5 queryFn: fetchNumber,
6 placeholderData: 23,
7 })
8
9 // ✅ same goes for initialData
10 const { data, status } = useQuery({
11 queryKey: ['number'],
12 queryFn: fetchNumber,
13 initialData: () => 42,
14 })
15}

Lastly, neither has an effect if you already have data in your cache. So what difference does it make if I use one or the other? To understand that, we have to briefly take a look at how (and on which "level") the options in React Query work:

On cache level

For each Query Key, there is only one cache entry. This is kinda obvious because part of what makes React Query great is the possibility to share the same data "globally" in our application.

Some options we provide to useQuery will affect that cache entry, prominent examples are staleTime and gcTime. Since there is only one cache entry, those options specify when that entry is considered stale, or when it can be garbage collected.

On observer level

An observer in React Query is, broadly speaking, a subscription created for one cache entry. The observer watches the cache entry for changes and will be informed every time something changes.

The basic way to create an observer is to call useQuery. Every time we do that, we create an observer, and our component will re-render when data changes. This of course means we can have multiple observers watching the same cache entry.

By the way, you can see how many observers a query has by the number on the left of the Query Key in the React Query Devtools (3 in this example):

observers

Some options that work on observer level would be select or keepPreviousData. In fact, what makes select so great for data transformations is the ability to watch the same cache entry, but subscribe to different slices of its data in different components.

Differences

InitialData works on cache level, while placeholderData works on observer level. This has a couple of implications:

Persistence

First of all, initialData is persisted to the cache. It's one way of telling React Query: I have "good" data for my use-case already, data that is as good as if it were fetched from the backend. Because it works on cache level, there can only be one initialData, and that data will be put into the cache as soon as the cache entry is created (meaning when the first observer mounts). If you try to mount a second observer with different initialData, it won't do anything.

PlaceholderData on the other hand is never persisted to the cache. I like to see it as "fake-it-till-you-make-it" data. It's "not real". React Query gives it to you so that you can show it while the real data is being fetched. Because it works on observer level, you can theoretically even have different placeholderData for different components.

Background refetches

With placeholderData, you will always get a background refetch when you mount an observer for the first time. Because the data is "not real", React Query will get the real data for you. While this is happening, you will also get an isPlaceholderData flag returned from useQuery. You can use this flag to visually hint to your users that the data they are seeing is in fact just placeholderData. It will transition back to false as soon as the real data comes in.

InitialData on the other hand, because data is seen as good and valid data that we actually put into our cache, respects staleTime. If you have a staleTime of zero (which is the default), you will still see a background refetch.

But if you've set a staleTime (e.g. 30 seconds) on your query, React Query will see the initialData and be like:

Oh, I'm getting fresh and new data here synchronously, thank you very much, now I don't need to go to the backend because this data is good for 30 seconds.

— React Query when it sees initialData and staleTime

If that's not what you want, you can provide initialDataUpdatedAt to your query. This will tell React Query when this initialData has been created, and background refetches will be triggered, taking this into account as well. This is extremely helpful when using initialData from an existing cache entry by using the available dataUpdatedAt timestamp:

initialDataUpdatedAt
1const useTodo = (id) => {
2 const queryClient = useQueryClient()
3
4 return useQuery({
5 queryKey: ['todo', id],
6 queryFn: () => fetchTodo(id),
7 staleTime: 30 * 1000,
8 initialData: () =>
9 queryClient
10 .getQueryData(['todo', 'list'])
11 ?.find((todo) => todo.id === id),
12 initialDataUpdatedAt: () =>
13 // ✅ will refetch in the background if our list query data
14 // is older than the provided staleTime (30 seconds)
15 queryClient.getQueryState(['todo', 'list'])?.dataUpdatedAt,
16 })
17}

Error transitions

Suppose you provide initialData or placeholderData, and a background refetch is triggered, which then fails. What do you think will happen in each situation? I've hidden the answers so that you can try to come up with them for yourselves if you want before expanding them.

InitialDataSince initialData is persisted in the cache, the refetch error is treated like any other background error. Our query will be in error state, but your data will still be there.

PlaceholderDataSince placeholderData is "fake-it-till-you-make-it" data, and we didn't make it, we won't see that data anymore. Our query will be in error state, and our data will be undefined.

When to use what

As always, that is totally up to you. I personally like to use initialData when pre-filling a query from another query, and I use placeholderData for everything else.


That's it for today. Feel free to reach out to me on twitter if you have any questions, or just leave a comment below. ⬇️