Data fetching in our React SPA

To fetch data inside a react component, we primarily need 2 things:

  1. Source

  2. Consumer

The source can be a REST or GraphQL endpoint.
Consumer in our case is a browser-based application.

A common pattern with React apps fetching data without using a 3rd party library:

We could in theory:

  1. come up with our custom hook

  2. abstract away the useEffect part

  3. expose the different states

One would think this is all it takes when in reality, it is no less than Pandora’s box. Here is why:

  • We now have to add logic to either ignore or cancel inflight requests which are very common in modern web apps.

  • Then we have to come up with a way to share the server-side state, normally done with some flavour of global state management or using React Context.

  • As with any global state, we are essentially creating a cache of data that we do not own. Any redux store or state stored in a top-level react context is in all practicality, a cache of the server-side state. And like all caches, this too needs to be invalidated somehow.

  • We will also need to think of the relationship between mutating our data by firing an UPDATE request and then invalidating our cache, which should trigger a re-fetch.

  • We also need to consider the following 2 scenarios and address them somehow:

Scenario 1

  1. User lands on /page-1, and before all of the data loads,
    user clicks on the link to go to /page-2

  2. The user spends some time on /page-2 and hits a browser back.

At this point, as we already got the chance to fetch the /page-1 data, do we show the old content while we re-fetch new data in the background?

Or we continue with the old content while ignoring the fact that it might have gotten stale by now?

Scenario 2

  1. User lands on /page-1, and before all of the data loads,
    user clicks on the link to go to /page-2 by mistake

  2. Realising their mistake, before /page-2 finishes data fetching,
    user hits the browser back button.

Assuming the 1st network call in /page-1 is still in progress, do we cancel it somehow
and use the 2nd one caused by the browser back?

Also, the network call in /page-2 that got triggered by the user mistakenly landing on it, do we now cancel it or let it warm up our cache?

All of this adds complexity and in essence what libraries like react query provides out of the box.

For our application, we could also hand-roll our data fetching hook by implementing all the requirements mentioned above for it to make sense and add to our maintenance overhead.

Or do a half-baked attempt at only fetching the data on every page load leading to a subpar user experience with loaders running all over the place (like the meme below)

To avoid the engineering overhead of implementing, testing and maintaining a fully-fledged data-fetching hook, we chose react-query out of the ones of suggested by the community: useSwr and react-query.

react-query works by accepting a function that returns a promise and then creating a cache of the resolved value, with a user-provided key.

The key is expected to be unique.
For example, if we are fetching data for user ID 1, the cache key should include the user ID along with some identifier.

This is what the code might look like:

const key = ['user', userId];
useQuery(key, {
    queryFn: () => fetchUser(userId)
})

Here, fetchUser can be any function that returns a promise.

Let's look at the component hierarchy at a high level:

Subscriptions render the heading and SubscriptionsContent component.

Inside SubscriptionsContent we fetch the data for both active and inactive tabs.

Until data loads, we show a top-level loader.
This is what the code looks like for data fetching:

const activeSubsQuery = useFetchSubscriptionList('ACTIVE');
const inactiveSubsQuery = useFetchSubscriptionList('INACTIVE');

useFetchSubscriptionList hook content looks like this:

export const useFetchSubscriptionList = (filter: Filter, sortOrder: SortOrder = 'asc') => {
  const showStubbedResults = useShowStubbedResults();

  const subscriptionsQuery = useQuery(['Subscriptions', filter, sortOrder], {
    queryFn: () => fetchSubscriptions(sortOrder, filter, showStubbedResults),
  });

  return subscriptionsQuery;
};

As the 2 hooks are invoked one after the other, React query can make a parallel network call.

The data is cached using this as cache-key ['Subscriptions', filter, sortOrder]

This means anytime our filter or sort order changes, it creates a whole new cache entry. It also means that because of the stale-while-revalidate nature of React query, changing the filter or sort order to previously fetched data immediately shows the stale data while it gets revalidated / re-fetched in the background.

It is possible to show a loading indicator while this is in progress.

Now inside the ActiveSubscriptionsTab and InActiveSubscriptionsTab we make add the following useFetchSubscriptionList(props.filter, sortOrder);

If you notice it is the same hook used in SubscriptionsContent. This will not trigger another fetch as the data is available already in React query cache and the component simply moves on to render it.

An exhaustive list of options returned by useQuery can be found on the official docs or their github.

And now onto a short demo. To show the loading speed, we will be throttling the network speed to slow 3g.

Note that after the 1st load, the list page does not show any loader. Same for the detail pages for 1st and 2nd list items: shows loader only 1 time to warm up the cache and going forward, only shows the cached content while the latest is re-fetched in the background.

The above app is live at: https://data-fetch-blog-demo.vercel.app/