Practical data fetching with React Suspense that you can use today

Andrei Duca
11 min readApr 18, 2020

--

This post was originally published on dev.to.

Photo by Franki Chamaki on Unsplash

It’s the hottest topic these days in the React community, and everybody gets either super excited or completely confused when the word “Suspense” is thrown around.

In this article, I’m not going to cover all the details of what the heck is up with this Suspense thing, as this has been discussed and explained numerous times, and the official docs are very explicit about the topic. Instead, I will show you how you can start using it today in your React projects.

TLDR?

yarn add use-async-resource

so you can:

Of course there’s more to it, so read on to find out.

“But I thought it’s experimental and we shouldn’t use it yet”

Concurrent mode is experimental! Suspense for lazy-loaded components, and even simple data fetching, works today. The React.Suspense component has been shipped since React 16.6, even before hooks!

All the other fancy things, like SuspenseList, useTransition, useDeferredValue, priority-based rendering etc are not officially out. But we’re not covering them here. We’re just trying to get started with the simple data fetching patterns, so when all these new things will be released, we can just improve our apps with them, building on top of the solutions that do work today.

So what is Suspense again?

In short, it’s a pattern that allows React to suspend the rendering of a component until some condition is met. In most cases, until some data is fetched from the server. The component is “suspended” if, instead of returning some JSX like it’s supposed to, it throws a promise. This allows React to render other parts of your app without your component being “ready”.

Fetching data from a server is always an asynchronous action. At the same time, the data a component needs in order to render should be available as a simple synchronous read.

Of course, the whole Suspense thing is much more than that, but this is enough to get you started.

In code, it’s a move from this:

to this:

Note: some details were omitted for simplicity.

If you haven’t figured it out yet, userReader is just a synchronous function that, when called, returns the user object. What is not immediately clear is that it also throws a promise if the data is not ready. The React.Suspense boundary will catch this and will render the fallback until the component can be safely rendered. Calling userReader can also throw an error if the async request failed, which is handled by the ErrorBoundary wrapper. At the same time, initializeUserReader will kick off the async call immediately.

This is the most basic example, and the docs get into way more detail about the concepts behind this approach, its benefits, and further examples about managing the flow of data in your app.

Ok, so how do we turn async calls into sync data reads?

First of all, the simplest way to get some async data is to have a function that returns a Promise, which ultimately resolves with your data; for simplicity, let’s call such functions “api functions”:

const fetchUser = id => fetch(`path/to/user/get/${id}`);

Here, we’re using fetch, but the Promise can be anything you like. We can even mock it with a random timeout:

const fetchUser = id =>
new Promise((resolve) => {
setTimeout(() => resolve({ id, name: 'John' }), Math.random() * 2000);
});

Meanwhile, our React component wants a function that just returns synchronous data; for consistency, let’s call this a “data reader function”:

const getUser = () => ({ id: 1, name: 'John' });

But in a Suspense world, we need a bit more than that: we also need to start fetching that data from somewhere, as well as throw the Promise if it’s not resolved yet, or throw the error if the request failed. We will need to generate the data reader function, and we’ll encapsulate the fetching and throwing logic.

The simplest (and most naive) implementation would look something like this:

If you’ve been reading other articles or even the official docs, you probably are familiar with this “special” pattern. It’s nothing special about it, really: you immediately start fetching the data, then you return a function that, when called, will give you the data if the async call is ready, or throw the promise if it’s not (or an error if it failed).

That’s exactly what we’ve been using in our previous example:

In the parent, we initialize the data reader, meaning we’re triggering the api call immediately. We get back that “special” function which the child component can call to access the data, throwing if not ready.

“But this is not practical enough…”

Yes, and if you’ve been reading anything about Suspense, this is also not new. It’s just an example to illustrate a pattern. So how do we turn it into something we can actually use?

First of all, it’s not correct. You probably spotted by now that, if the App component updates for any other reason, the data reader gets re-initialized. So even if an api call is already in progress, if the App component re-renders, it will trigger another api call. We can solve this by keeping our data reader function in a local state:

Next, we will probably need to fetch new data based on a new user id. Again, the setter function from useState can help us:

It looks better, but we’re starting to see a lot of repetitions. Plus, it’s hardcoded for our fetchUser api function. We need something more generic.

Let’s change the initializer to accept an api function, any. We will also need to pass all the parameters the api function might need, if any.

Our initializer now works with ANY api function which accepts ANY number of parameters (or even none). Everything else remains unchanged:

But we’re still facing the repetition problem when we need to fetch new data because we always need to pass the api function to the initializer. Time for a custom hook!

Here, we’ve encapsulated the logic of initializing both the data reader and the updater function. Now, when we need to fetch new data, we will never have to specify the api function again. We also return them as a tuple (a pair), so we can name them whatever we want when we use them:

Again, everything else remains unchanged: we still pass the generated data reader function to the “suspendable” component that will call it in order to access the data, and we wrap that component in a Suspense boundary.

Taking it further

Our custom useAsyncResource hook is simple enough, yet it works for most use cases. But it also needs other features that have proven useful in practice. So let’s try to implement them next.

Lazy initialization

In some cases, we don’t want to start fetching the data immediately, but rather we need to wait for a user’s action. We might want to lazily initialize the data reader.

Let’s modify our custom hook so that when it gets the api function as the only argument, we won’t start fetching the data, and the data reader function will return undefined (just like an unassigned variable). We can then use the updater function to start fetching data on demand, just like before.

This might work for api functions that take arguments, but now how do we eagerly initialize a data reader for an api function that doesn’t take any arguments? Well, as a convention, let’s specify that in order to eagerly initialize such functions, the custom hook will expect an empty array as a second argument (just like React hooks!).

In short, passing the api function params to the hook will kick off the api call immediately; otherwise, it won’t. All cases would work on the same principle:

Implementing this will require some changes to our custom hook:

Transforming the data upon reading

In other cases, the data you get back might be a full response from the server, or a deeply nested object, but your component only needs a small portion from that, or even a completely transformed version of your original data. Wouldn’t it be nice if, when reading the data, we can easily transform it somehow?

We will need to add this functionality to our data reader initializer:

What about TypeScript?

If you use TypeScript in your project, you might want to have this custom hook fully typed. You’d expect the data reader function to return the correct type of the data your original api function was returning as a Promise. Well, this is where things can get complicated. But let’s try…

First, we know we are working with many types, so let’s define them in advance to make everything more readable.

That was a lot, but we covered all the types that we’re going to use:

  • we start from a simple api function ApiFn<R, A ...> and we’ll want to end up with a simple data reader function DataFn<R>;
  • this data reader function my return undefined if it’s lazily initialized, so we’ll also use LazyDataFn<R>;
  • our custom hook will correctly return one or the other based on how we initialize it, so we’ll need to keep them separate;
  • the data reader function can accept an optional modifier function as a parameter, in which case it will return a modified type instead of the original data type (therefore ModifiedDataFn<R> or LazyModifiedDataFn<R>); without it, it should just return the data type;
  • to satisfy both these conditions (with or without the modifier function), we’ll actually use DataOrModifiedFn<R> and LazyDataOrModifiedFn<R> respectively;
  • we also get back an updater function UpdaterFn<R, A ...>, with a similar definition as the original api function.

Let’s start with the initializer. We know we’re going to have two types of api functions: with arguments, and without arguments. We also know that the initializer will always kick off the api call, meaning the data reader is always eagerly generated. We also know that the returned data reader can have an optional modifier function passed to it.

Pretty complex, but it will get the job done.

Now let’s continue typing the custom hook. We know there are 3 use cases, so we’ll need 3 overloads: lazy initializing, eager initializing for api functions without arguments, and eager initializing for api functions with arguments.

And the implementation that satisfies all 3 overloads:

Now our custom hook should be fully typed and we can take advantage of all the benefits TypeScript gives us:

Note how all types are inferred: we don’t need to manually specify them all over the place, as long as the api function has its types defined.

Trying to call updateUserReader with other parameter types will trigger a type error. TS will also complain if we pass the wrong parameters to useAsyncResource.

However, if we don’t pass any arguments to the hook other than the api function, the data reader will be lazily initialized:

Using the data reader with a modifier function also works as expected:

Resource caching

There’s one more thing our custom hook is missing: resource caching. Subsequent calls with the same parameters for the same api function should return the same resource, and not trigger new, identical api calls. But we’d also like the power to clear cached results if we really wanted to re-fetch a resource.

In a very simple implementation, we would use a Map with a hash function for the api function and the params as the key, and the data reader function as the value. We can go a bit further and create separate Map lists for each api function, so it’s easier to control the caches.

*Note: we’re using a naive “hashing” method here by converting the parameters to a simple JSON string. In a real scenario, you would want something more sophisticated, like object-hash.

Now we can just use this in our data reader initializer:

That’s it! Now our resource is cached, so if we request it multiple times, we’ll get the same data reader function.

If we want to clear a cache so we can re-fetch a specific piece of data, we can manually do so using the helper function we just created:

In this case, we’re clearing the entire cache for the fetchLatestPosts api function. But you can also pass parameters to the helper function, so you only delete the cache for those specific ones:

Future-proofing

We said in the beginning that the shiny new stuff is still in the works, but we’d like to take advantage of them once they’re officially released.

So is our implementation compatible with what’s coming next? Well, yes. Let’s quickly look at some.

Enabling Concurrent Mode

First, we need to opt into making (the experimental version of) React work in concurrent mode:

SuspenseList

This helps us coordinate many components that can suspend by orchestrating the order in which these components are revealed to the user.

In this example, if the posts load faster, React still waits for the user data to be fetched before rendering anything.

useTransition

This delays the rendering of a child component being suspended, rendering with old data until the new data is fetched. In other words, it prevents the Suspense boundary from rendering the loading indicator while the suspendable component is waiting for the new data.

Here, the ...loading user message is not displayed while a new random user is being fetched, but the button is disabled. If fetching the new user data takes longer than 1 second, then the loading indicator is shown again.

Conclusion

With a little bit of work, we managed to make ourselves a nice wrapper for api functions that works in a Suspense world. More importantly, we can start using this today!

In fact, we already use it in production at OpenTable, in our Restaurant product. We started playing around with this at the beginning of 2020, and we now have refactored a small part of our application to use this technique. Compared to previous patterns we were using (like Redux-Observables), this one brings some key advantages that I’d like to point out.

It’s simpler to write, read and understand

Treating data like it’s available synchronously makes the biggest difference in the world, because your UI can fully be declarative. And that’s what React is all about!

Not to mention the engineering time saved by shaving off the entire boilerplate that Redux and Redux-Observables were requiring. We can now write code much faster and more confident, bringing projects to life in record time.

It’s “cancellable”

Although not technically (you can’t prevent a fetch or a Promise to fulfill), as soon as you instantiate a new data reader, the old one is discarded. So stale or out of order updates just don’t happen anymore!

This used to bring a lot of headaches to the team with traditional approaches. Then, after adopting Redux-Observables, we had to write A LOT of boilerplate: registering epics, listening for incoming actions, switch-mapping and calling the api (thus canceling any previously triggered one), finally dispatching another action that would update our redux store.

It’s nothing new

All the Redux + Observables code was living in external files too, so it would make understanding the logic of a single component way harder. Not to mention the learning curve associated with all this. Junior engineers would waste precious time reading cryptic code and intricate logic, instead of focusing on building product features.

Instead, now we just update the data reader by calling the updater function! And that’s just plain old JavaScript.

In closing, I’d like to leave you with this thread about “Why Suspense matters” so much. Ultimately, I think the beauty of the entire thing is in its simplicity.

--

--