RTK Query's system of hooks makes it easier than ever to call web API endpoints from within React components. However, these hooks are not pure functions - they also depend on external factors like server availability and the passage of time, and this can lead to some gotchas when combining API calls. 

This post will guide the reader through a pitfall we encountered while making a network waterfall. In its simplest form, a network waterfall is just a program loading one value through a network, and then turning around and using that value to make another load. 

The Example App

In this post, we're going to imagine a product catalog where a user enters a search query, and gets back a list of products for sale. The site displays various facts about the product, like its name. In a minute, we'll also display the one-to-four star rating of a product, which is loaded from a separate endpoint. But first, let's show our root component, the SearchScreen:

the SearchScreen component: A text input and a list of resulting catalog items a buyer might want to buy

Here's the code for it:

export const SearchScreen = () => {
    const [query, setQuery] = useState("fox");
    const { data } = useSearchQuery(query);

    return (<div>
                <h1>Search</h1>
                <SearchBox query={query} setQuery={setQuery} />
                <SearchResults searchResults={data} />
            </div>)
}

The most relevant thing about this code is line 3: useSearchQuery. This is connected to a remote web API endpoint. The hook takes a string ("fox") and kicks back either undefined or an array of Product.  It's tough to imagine a more elegant system for loading data!

For simplicity, a Product in this article is only an ID string and a human-readable name:

export type Id = string;
export type Product = { id: Id, name: string };

SearchScreen's Product array is passed to a SearchResults component for display:

export const SearchResults = ({searchResults} : {searchResults: Product[] | undefined}) => 
  (<ul>{searchResults 
        ? searchResults.map(({id, name}) => <li key={id}>{name}</li>) 
        : [1,2,3,4].map(n => <li key={n}>...</li>)}
   </ul>)

In this example, if searchResults is defined,  we map the products into markup for displaying them. If it's undefined, then the component displays four empty products with the label '…'. This can happen, for example, if the web API is fetching or if there is an error.

SearchResults is a pure component. We prefer to put most of our styling into pure components, to keep the logic separated. This paid off in a couple of cases where we had different network components invoking one shared pure component. 

Timing

Just to play around with the loading sequence a little further, if we change the query from 'fox' to 'box', the update animation looks like this:

slow motion loading

The key thing to notice here is that after 'box' is first entered, there's a moment where the search results still display fox-related items, not what the user wanted, which is box-related items. Then everything settles down and box-related items are displayed. 

It's helpful to think of the timeline of values flowing through the system. In this article, "fox" related data will be represented in green, and "box" related data in purple.

 This timeline shows three phases corresponding to frames of the animation above.

  1. green fox query, green fox searchResults, then
  2. purple box query, stale green fox searchResults, then
  3. purple box query, purple box searchResults

Phase 2 is the most interesting. It's RTK Query trying to choose a good default behavior for us, and in this case it will work well for us. The user has edited the query, and a short time later the searchResults update with new data. There's no heavy-handed animation flash telling the user that new data's being loaded, and from a human point of view with such a short delay in a demo app, there's no point in drawing attention to it. 

Waterfalls

Now let's see how this approach fares with a more complicated networking setup, namely: a waterfall. Let's try to build this:

 

 

We're adding stars to the SearchResults component to represent average customer ratings for our products. As the fates would have it, the star data is not conveniently stashed in a field of the Product type, but rather only available from a separate endpoint. 

This situation is a simple waterfall. Waterfalls will occur when you need the results of one endpoint as an input to a second endpoint. Here's our updated network component:

 export const SearchScreenRatedBuggy = () => {
    const [query, setQuery] = useState("fox");
    const { data: searchResults } = useSearchQuery(query);
    const { data: stars } = useRatingQuery(searchResults?.map(p => p.id) ?? skipToken);
    // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^new^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    return (<div>
                <h1>Search</h1>
                <SearchBox query={query} setQuery={setQuery} />
                <SearchResultsRated searchResults={searchResults} stars={stars} />
                //                                                ^^^^^new^^^^^
            </div>)
}

There are two changes. Let's start with the second one: the call to the SearchResultsRated pure component now takes a new prop, stars. It's an object of type Record<Id,number>, mapping product Ids to a number of stars. 

The other new line loads that stars value. 

    const { data: stars } = useRatingQuery(searchResults?.map(p => p.id) ?? skipToken);

Let's break this line down in detail. The first thing to notice is a seemingly new hook useRatingQuery. This is a hook that RTK Query helpfully builds for us, exactly the same way that it built useSearchQuery. Concerning the line's left hand side, we're referring to the data prop as stars, because we want to be super-clear and not get it confused with the data from the useSearchQuery call one line above this. Meanwhile on the right, expressing the parameter to useRatingQuery, there's some logic going on.  useRatingQuery normally takes an argument of an array of product Ids - that's what searchResults.map(p => p.id) gives us. However, searchResults is sometimes undefined (notably when it's loading) and in that case we aren't ready to make the second step of our waterfall. One way RTK Query lets you skip the call is by passing the symbol skipToken as an argument to your hook. We do that here. 

Let's also show our pure display component, which I've renamed from SearchResults to SearchResultsRated:


export const SearchResultsRated = ({searchResults, stars} : 
                                   {searchResults: Product[] | undefined,
                                    stars: Record<Id,number> | undefined
                                   }) => {
    return (<ul>{searchResults && stars // added '&& stars' guard
                 ? searchResults.map(({id, name}) => {
                     const starCount = stars[id];
                     if (starCount === undefined)
                       // this'll never happen, RIGHT?
                       throw new Error(`Could not find ${id} in ${JSON.stringify(stars)}`);
                     return <li key={id}>{name} {"⭐️".repeat(starCount)}</li>
                   })
                 : [1,2,3,4].map(n => <li key={n}>...</li>)
                }
            </ul>);
}

The Gotcha 🐞

When we give this code a whirl, everything works great at first. But at the fateful moment when we change our query from "fox" to "box", we get the foreshadowed error message:

Unhandled Runtime Error

Error: Could not find f727e1ad-3ed6-4a20-8bac-bea8f86571b0 in 
       {"25552b5b-7f56-4e0a-beae-c89426139610":2,
        "6ac886d6-7b0d-4b70-b6de-2b25c756c356":4,
        "7b824d8f-1216-4360-8dfb-d48979e4c131":1,
        "6e782530-0e7a-47d4-8ef6-72acc6ad111b":3}

How could our glorious plan have failed? Well, it's that weird moment when we're calling a hook with new parameters, but it's still returning stale parameters. Before we saw how this helpfully cut down on flickering, but now we're paying a price. Let's look at the timeline:

The error gets thrown at the moment when searchResults contains new purple box data, but it tries to dereference an Id into stale green stars. Bummer!

The Solution

RTK Query added a solution for this. In addition to the data property output from its hooks, it also now supports a currentData property. They usually have the same value. The exception is when the hook is fetching a subsequent value. In that case, currentData is undefined instead of a stale value. That's exactly what we need. 

So if we change one identifier in our SearchScreen function to use currentData, the program behaves properly. 

export const SearchScreenRated = () => {
    const [query, setQuery] = useState("fox");
    const { data: searchResults } = useSearchQuery(query);
    const { currentData: stars } = useRatingQuery(searchResults?.map(p => p.id) ?? skipToken);
    //      ^^^^^^^^^^^ currentData and not data
    return (<div>
                <h1>Search</h1>
                <SearchBox query={query} setQuery={setQuery} />
                <SearchResultsRated searchResults={searchResults} stars={stars} />
            </div>)
}
the final animation, in slow motion

The fix works in part because you'll recall that SearchResultsRated doesn't try to render any data until both its searchResults and stars props are defined. Instead, it renders the loading skeletons. That was good enough for us, but if the display of stale data was really critical, one could rig up a caching system using useState. Hey, let's leave that as an exercise to the reader!

Conclusions

I hope you've enjoyed this deep dive into RTK Query hook return values. 

RTK Query docs recommend you use data most of the time, but we found that to be a source of errors like this. We were more comfortable with displaying loading skeletons in these cases. Because of this we were confident enough to substantially replace references to data with currentData throughout our app, minimizing surprises caused by stale data.  

Additional Notes

Code

The examples in this blog were tested using this repository: https://github.com/dave-morse-wwt/searchbox.

There's also a spike branch with cooler looking loading skeletons, thanks to the very promising looking react-loading-skeleton.

On undefined meaning "fetching"…

We've passed a lot of asynchronously loaded network data around as props. In this article we've used the convention of passing undefined when the data hasn't been loaded yet, or when there's been an error. That's a good clear choice for a short blog post, but you may find yourself on uncertain ground as various other duties get thrown onto undefined's shoulders. For example, we had a component that could optionally take some asynchronously loaded network data, in which case it wasn't clear if either:

  1. the data was incoming, just not loaded yet, or
  2. the data was never going to be specified

Additionally, sometimes we wanted a component to display differently if data was still expected to stream in someday, or if there was a network error. For that reason, we eventually evolved from encoding fetching as undefined. Instead, we began to pass a unique symbol fetching for that purpose. On rare occasions where we cared to display differently if there was an error, we passed another symbol netError. This proved to be a good middle ground between just one nullish value (too little information) and passing the entire hook return value (too much information). 

isLoading and isFetching

In addition to data and currentData, the UseQueryResult object returned by RTK Query also has several other properties.  Let's examine two of them.

  • isLoading is true the first time your hook fires, but then flips to false as soon as it has its first result, and never changes afterwards. Thus, its behavior is similar to the expression !data.
  • isFetching is true every time your hook fires, and flips back to false as soon as it gets any results. It continues flip-flopping as long as there's a network request in flight. Thus, its behavior is similar to the expression !currentData.

When I say "similar", I'm intentionally glossing over some other states, such as the error state. Because of this ambiguity, and because of the way that TypeScript inference works with them, I find it better to treat these two flags as if they don't exist. 

Instead, I prefer to directly compare currentData or data to undefined. TypeScript generously supports this style with its ?. and ?? operators. 

Keep your skeleton close to you at all times

Early in a recent project we considered making two versions of every pure display component, one for real data, and one for loading skeletons. However, we found it was too easy for the styles of the two components to get out of sync. We ended up forcing our display components to handle the logic of missing data themselves. This kept style drift to a minimum by making it impossible for a programmer to overlook the skeleton case while altering the data case.