Hackernoon logoPractical Data Fetching with React Suspense That You Can Use Today by@andreiduca

Practical Data Fetching with React Suspense That You Can Use Today

Author profile picture

@andreiducaAndrei Duca

Sr. Frontend Engineer @ OpenTable | creator of Snipit.io

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.
This post was originally published on dev.to.
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:
import { useAsyncResource } from 'use-async-resource';
 
// a simple api function that fetches a user
const fetchUser = (id: number) => fetch(`.../get/user/by/${id}`).then(res => res.json());
 
function App() {
  // --> initialize the data reader and start fetching the user immediately
  const [userReader, getNewUser] = useAsyncResource(fetchUser, 1);
 
  return (
    <>
      <ErrorBoundary>
        <React.Suspense fallback="user is loading...">
          <User userReader={userReader} /* <-- pass it to a suspendable child component */ />
        </React.Suspense>
      </ErrorBoundary>
      <button onClick={() => getNewUser(2)}>Get user with id 2</button>
      {/* --> clicking the button will start fetching a new user */}
    </>
  );
}
 
function User({ userReader }) {
  const userData = userReader(); // <-- just call the data reader function to get the user object
 
  return <div>{userData.name}</div>;
}
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:
function User(props) {
  const [user, setUser] = useState();
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState();

  useEffect(() => {
    setLoading(true);
    fetchUser(props.id)
      .then((userResponse) => {
        setUser(userResponse);
        setLoading(false);
      )
      .catch((e) => {
        setError(e);
        setLoading(false);
      );
  }, [props.id]);

  if (loading) return <div>loading...</div>;
  if (error) return <div>something happened :(</div>;

  return <div>{user.name}</div>;
}

function App() {
  return <User id={someIdFromSomewhere} />;
}
to this:
function User(props) {
  const user = props.userReader();

  return <div>{user.name}</div>;
}

function App() {
  const userReader = initializeUserReader(someIdFromSomewhere);

  return (
    <ErrorBoundary error="something went wrong with the user :(">
      <React.Suspense fallback="loading...">
        <User userReader={userReader} />
      </React.Suspense>
    </ErrorBoundary>
  );
}
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 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:
const initializeUserReader = (id) => {
  // keep data in a local variable so we can synchronously request it later
  let data;
  // keep track of progress and errors
  let status = 'init';
  let error;
  
  // call the api function immediately, starting fetching
  const fetchingUser = fetchUser(id)
    .then((user) => {
      data = user;
      status = 'done';
    })
    .catch((e) => {
      error = e;
      status = 'error';
    });

  // this is the data reader function that will return the data,
  // or throw if it's not ready or has errored
  return () => {
    if (status === 'init') {
      throw fetchingUser;
    } else if (status === 'error') {
      throw error;
    }

    return data;
  }
};
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 AppComponent
const userReader = initializeUserReader(someIdFromSomewhere);

return (
  // ...
  <React.Suspense fallback="loading...">
    <User userReader={userReader} />
  </React.Suspense>
);

// in UserComponent
const user = props.userReader();

return <div>{user.name}</div>;
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 generated data reader function in a local state:
// in AppComponent
const [userReader] = useState(() => initializeUserReader(someId));
Next, we will probably need to fetch new data based on a new user id. Again, the setter function from 
useState
can help us:
const [userReader, updateReader] = useState(() => initializeUserReader(someId));

const btnClickCallback = useCallback((newUserId) => {
  updateReader(() => initializeUserReader(newUserId));
}, []);

return (
  // ...
  <button onClick={() => btnClickCallback(1)}>
    get user with id 1
  </button>
);
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.
const initializeDataReader = (apiFn, ...parameters) => {
  // ...

  const fetcingPromise = apiFn(...parameters)
    .then(/* ... */)
    // ...

  // ...
};
Our initializer now works with ANY api function which accepts ANY number of parameters (or even none). Everything else remains unchanged.
const [userReader, updateUserReader] = useState(() => initializeDataReader(fetchUser, userId));
const [postsReader, updatePostsReader] = useState(() => initializeDataReader(fetchPostByTags, 'react', 'suspense', 'data', 'fetching'));

const getNewUser = useCallback((newUserId) => {
  updateUserReader(() => initializeDataReader(fetchUser, newUserId));
}, []);

const getNewPosts = useCallback((...tags) => {
  updatePostsReader(() => initializeDataReader(fetchPostByTags, ...tags));
}, []);
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!
const useAsyncResource = (apiFunction, ...parameters) => {
  const [dataReader, updateDataReader] = useState(() => initializeDataReader(apiFunction, ...parameters));

  const updater = useCallback((...newParameters) => {
    updateDataReader(() => initializeDataReader(apiFunction, ...newParameters));
  }, [apiFunction]);

  return [dataReader, updater];
};
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:
const [userReader, refreshUserReader] = useAsyncResource(fetchUser, userId);

const onBtnClick = useCallback((newId) => {
  refreshUserReader(newId);
}, []);
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.
const [userReader, refreshUserReader] = useAsyncResource(fetchUser);

const btnClick = useCallback((userId) => {
  refreshUserReader(userId);
}, []);

// calling userReader() now would return `undefined`, unless a button is clicked
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!).
// this api function doesn't take any arguments
const fetchLatestPosts = () => fetch('path/to/latest/posts');

// eagerly initialized data reader, will start fetching immediately
const [latestPosts, refreshLatestPosts] = useAsyncResource(fetchLatestPosts, []);


// lazily initialized, won't start fetching until the button is clicked
const [latestPosts, getLatestPosts] = useAsyncResource(fetchLatestPosts);

const startFetchingLatestsPosts = useCallback(() => {
  // this will kick off the api call
  getLatestPosts();
}, []);

return (
  <button onClick={startFetchingLatestsPosts}>
    get latest posts
  </button>
);
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:
// lazily initialized data readers
const [userReader, refreshUserReader] = useAsyncResource(fetchUser);
const [latestPosts, getLatestPosts] = useAsyncResource(fetchLatestPosts);

// eagerly initialized data readers
const [userReader, refreshUserReader] = useAsyncResource(fetchUser, userId);
const [latestPosts, refreshLatestPosts] = useAsyncResource(fetchLatestPosts, []);
Implementing this will require some changes to our custom hook:
const useAsyncResource = (apiFunction, ...parameters) => {
  // initially defined data reader
  const [dataReader, updateDataReader] = useState(() => {
    // lazy initialization, when no parameters are passed
    if (!parameters.length) {
      // we return an empty data reader function
      return () => undefined;
    }

    // eager initialization for api functions that don't accept arguments
    if (
      // check that the api function doesn't take any arguments
      !apiFunction.length
      // but the user passed an empty array as the only parameter
      && parameters.length === 1
      && Array.isArray(parameters[0])
      && parameters[0].length === 0
    ) {
      return initializeDataReader(apiFunction);
    }

    // eager initialization for all other cases
    // (i.e. what we previously had)
    return initializeDataReader(apiFunction, ...parameters);
  });

  // the updater function remains unchaged
  const updater = useCallback((...newParameters) => {
    updateDataReader(() => initializeDataReader(apiFunction, ...newParameters));
  }, [apiFunction]);

  return [dataReader, updater];
};

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?
// transform function
function friendsCounter(userObject) {
  return userObject.friendsList.length;
}

function UserComponent(props) {
  const friendsCount = props.userReader(friendsCounter);

  return <div>Friends: {friendsCount}</div>;
}
We will need to add this functionality to our data reader initializer:
const initializeDataReader = (apiFn, ...parameters) => {
  // ...

  return (modifier) => {
    if (status === 'init') // ...
      // ... throwing like before

    return typeof modifier === 'function'
      // apply a transformation if it exists
      ? modifier(data)
      // otherwise, return the unchanged data
      : data;
  }
};

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.
// a typical api function: takes an arbitrary number of arguments of type A
// and returns a Promise which resolves with a specific response type of R
type ApiFn<R, A extends any[] = []> = (...args: A) => Promise<R>;

// an updater function: has a similar signature with the original api function,
// but doesn't return anything because it only triggers new api calls
type UpdaterFn<A extends any[] = []> = (...args: A) => void;

// a simple data reader function: just returns the response type R
type DataFn<R> = () => R;
// a lazy data reader function: might also return `undefined`
type LazyDataFn<R> = () => (R | undefined);

// we know we can also transform the data with a modifier function
// which takes as only argument the response type R and returns a different type M
type ModifierFn<R, M = any> = (response: R) => M;

// therefore, our data reader functions might behave differently
// when we pass a modifier function, returning the modified type M
type ModifiedDataFn<R> = <M>(modifier: ModifierFn<R, M>) => M;
type LazyModifiedDataFn<R> = <M>(modifier: ModifierFn<R, M>) => (M | undefined);

// finally, our actual eager and lazy implementations will use
// both versions (with and without a modifier function),
// so we need overloaded types that will satisfy them simultaneously
type DataOrModifiedFn<R> = DataFn<R> & ModifiedDataFn<R>;
type LazyDataOrModifiedFn<R> = LazyDataFn<R> & LazyModifiedDataFn<R>;
That was a lot, but we covered all the types that we’re going to use:
  1. 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>
    ;
  2. this data reader function my return
    undefined
    if it’s lazily initialized, so we’ll also use
    LazyDataFn<R>
    ;
  3. our custom hook will correctly return one or the other based on how we initialize it, so we’ll need to keep them separate;
  4. 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;
  5. to satisfy both these conditions (with or without the modifier function), we’ll actually use
    DataOrModifiedFn<R>
    and
    LazyDataOrModifiedFn<R>
    respectively;
  6. 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.
// overload for wrapping an apiFunction without params:
// it only takes the api function as an argument
// it returns a data reader with an optional modifier function
function initializeDataReader<ResponseType>(
  apiFn: ApiFn<ResponseType>,
): DataOrModifiedFn<ResponseType>;

// overload for wrapping an apiFunction with params:
// it takes the api function and all its expected arguments
// also returns a data reader with an optional modifier function
function initializeDataReader<ResponseType, ArgTypes extends any[]>(
  apiFn: ApiFn<ResponseType, ArgTypes>,
  ...parameters: ArgTypes
): DataOrModifiedFn<ResponseType>;

// implementation that covers the above overloads
function initializeDataReader<ResponseType, ArgTypes extends any[] = []>(
  apiFn: ApiFn<ResponseType, ArgTypes>,
  ...parameters: ArgTypes
) {
  type AsyncStatus = 'init' | 'done' | 'error';

  let data: ResponseType;
  let status: AsyncStatus = 'init';
  let error: any;

  const fetcingPromise = apiFn(...parameters)
    .then((response) => {
      data = response;
      status = 'done';
    })
    .catch((e) => {
      error = e;
      status = 'error';
    });

  // overload for a simple data reader that just returns the data
  function dataReaderFn(): ResponseType;
  // overload for a data reader with a modifier function
  function dataReaderFn<M>(modifier: ModifierFn<ResponseType, M>): M;
  // implementation to satisfy both overloads
  function dataReaderFn<M>(modifier?: ModifierFn<ResponseType, M>) {
    if (status === 'init') {
      throw fetcingPromise;
    } else if (status === 'error') {
      throw error;
    }

    return typeof modifier === "function"
      ? modifier(data) as M
      : data as ResponseType;
  }

  return dataReaderFn;
}
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.
// overload for a lazy initializer:
// the only param passed is the api function that will be wrapped
// the returned data reader LazyDataOrModifiedFn<ResponseType> is "lazy",
//   meaning it can return `undefined` if the api call hasn't started
// the returned updater function UpdaterFn<ArgTypes>
//   can take any number of arguments, just like the wrapped api function
function useAsyncResource<ResponseType, ArgTypes extends any[]>(
  apiFunction: ApiFn<ResponseType, ArgTypes>,
): [LazyDataOrModifiedFn<ResponseType>, UpdaterFn<ArgTypes>];

// overload for an eager initializer for an api function without params:
// the second param must be `[]` to indicate we want to start the api call immediately
// the returned data reader DataOrModifiedFn<ResponseType> is "eager",
//   meaning it will always return the ResponseType
//   (or a modified version of it, if requested)
// the returned updater function doesn't take any arguments,
//   just like the wrapped api function
function useAsyncResource<ResponseType>(
  apiFunction: ApiFn<ResponseType>,
  eagerLoading: never[], // the type of an empty array `[]` is `never[]`
): [DataOrModifiedFn<ResponseType>, UpdaterFn];

// overload for an eager initializer for an api function with params
// the returned data reader is "eager", meaning it will return the ResponseType
//   (or a modified version of it, if requested)
// the returned updater function can take any number of arguments,
//   just like the wrapped api function
function useAsyncResource<ResponseType, ArgTypes extends any[]>(
  apiFunction: ApiFn<ResponseType, ArgTypes>,
  ...parameters: ArgTypes
): [DataOrModifiedFn<ResponseType>, UpdaterFn<ArgTypes>];
And the implementation that satisfies all 3 overloads:
function useAsyncResource<ResponseType, ArgTypes extends any[]>(
  apiFunction: ApiFn<ResponseType> | ApiFn<ResponseType, ArgTypes>,
  ...parameters: ArgTypes
) {
  // initially defined data reader
  const [dataReader, updateDataReader] = useState(() => {
    // lazy initialization, when no parameters are passed
    if (!parameters.length) {
      // we return an empty data reader function
      return (() => undefined) as LazyDataOrModifiedFn<ResponseType>;
    }

    // eager initialization for api functions that don't accept arguments
    if (
      // ... check for empty array param
    ) {
      return initializeDataReader(apiFunction as ApiFn<ResponseType>);
    }

    // eager initialization for all other cases
    return initializeDataReader(apiFunction as ApiFn<ResponseType, ArgTypes >, ...parameters);
  });

  // the updater function
  const updater = useCallback((...newParameters: ArgTypes) => {
    updateDataReader(() =>
      initializeDataReader(apiFunction as ApiFn<ResponseType, ArgTypes >, ...newParameters)
    );
  }, [apiFunction]);

  return [dataReader, updater];
};
Now our custom hook should be fully typed and we can take advantage of all the benefits TypeScript gives us:
interface User {
  id: number;
  name: string;
  email: string;
}

const fetchUser = (id: number): Promise<User> => fetch(`path/to/user/${id}`);


function AppComponent() {
  const [userReader, updateUserReader] = useAsyncResource(fetchUser, someIdFromSomewhere);
  // `userReader` is automatically a function that returns an object of type `User`
  // `updateUserReader` is automatically a function that takes a single argument of type number

  return (
    // ...
    <React.Suspense fallback="loading...">
      <UserComponent userReader={userReader} />
    </React.Suspense>
  );
}

function UserComponent(props) {
  // `user` is automatically an object of type User
  const user = props.userReader();

  // your IDE will happily provide full autocomplete for this object
  return <div>{user.name}</div>;
}
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
.
// TS will complain about this
const [userReader, updateUserReader] = useAsyncResource(fetchUser, 'some', true, 'params');

// and this
updateUserReader('wrong', 'params');
However, if we don’t pass any arguments to the hook other than the api function, the data reader will be lazily initialized:
function AppComponent() {
  const [userReader, updateUserReader] = useAsyncResource(fetchUser);
  // `userReader` is a function that returns `undefined` or an object of type `User`
  // `updateUserReader` is still a function that takes a single argument of type number

  const getNewUser = useCallback((newUserId: number) => {
    updateUserReader(newUserId);
  }, []);

  return (
    // ...
    <button onClick={() => getNewUser(1)}>
      load user with id 1
    </button>
    <React.Suspense fallback="loading...">
      <UserComponent userReader={userReader} />
    </React.Suspense>
  );
}

function UserComponent(props) {
  // here, `user` is `undefined` unless the button is clicked
  const user = props.userReader();

  // we need to add a type guard to get autocomplete further down
  if (!user) {
    return null;
  }

  // now autocomplete works again for the User type object
  return <div>{user.name}</div>;
}
Using the data reader with a modifier function also works as expected:
// a pure function that transforms the data of type User
function getUserDisplayName(userObj: User) {
  return userObj.firstName + ' ' + userObj.lastName;
}

function UserComponent(props) {
  // `userName` is automatically typed as string
  const userName = props.userReader(getUserDisplayName);

  return <div>Name: {userName}</div>;
}

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.
const caches = new Map();

export function resourceCache<R, A extends any[]>(
  apiFn: ApiFn<R, A>,
  ...params: A | never[]
) {
  // if there is no Map list defined for our api function, create one
  if (!caches.has(apiFn)) {
    caches.set(apiFn, new Map());
  }

  // get the Map list of caches for this api function only
  const apiCache: Map<string, DataOrModifiedFn<R>> = caches.get(apiFn);

  // "hash" the parameters into a unique key*
  const pKey = JSON.stringify(params);

  // return some methods that let us control our cache
  return {
    get() {
      return apiCache.get(pKey);
    },
    set(data: DataOrModifiedFn<R>) {
      return apiCache.set(pKey, data);
    },
    delete() {
      return apiCache.delete(pKey);
    },
    clear() {
      return apiCache.clear();
    }
  };
}
*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:
function initializeDataReader(apiFn, ...parameters) {
  // check if we have a cached data reader and return it instead
  const cache = resourceCache(apiFn, ...parameters);
  const cachedResource = cache.get();

  if (cachedResource) {
    return cachedResource;
  }

  // otherwise continue creating it
  type AsyncStatus = 'init' | 'done' | 'error';
  // ...

  function dataReaderFn(modifier) {
    // ...
  }

  // cache the newly generated data reader function
  cache.set(dataReaderFn);

  return dataReaderFn;
}
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:
const [latestPosts, getPosts] = useAsyncResource(fetchLatestPosts, []);

const refreshLatestPosts = useCallback(() => {
  // clear the cache so we force a new api call
  resourceCache(fetchLatestPosts).clear();
  // refresh the data reader
  getPosts();
}, []);

return (
  // ...
  <button onClick={refreshLatestPosts}>get fresh posts</button>
  // ...
);
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:
const [user, getUser] = useAsyncResource(fetchUser, id);

const refreshUserProfile = useCallback(() => {
  // only clear cache for user data reader for that id
  resourceCache(fetchUser, id).delete();
  // get new user data
  getUser(id);
}, [id]);

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:
const rootElement = document.getElementById("root");

ReactDOM.createRoot(rootElement).render(<App />);
// instead of the traditional ReactDOM.render(<App />, rootElement)

SuspenseList

This helps us coordinate many components that can suspend by orchestrating the order in which these components are revealed to the user.
<React.SuspenseList revealOrder="forwards">
  <React.Suspense fallback={<div>...user</div>}>
    <User userReader={userReader} />
  </React.Suspense>
  <React.Suspense fallback={<div>...posts</div>}>
    <LatestPosts postsReader={postsReader} />
  </React.Suspense>
</React.SuspenseList>
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.
const [user, getUser] = useAsyncResource(fetchUser, 1);
const [startLoadingUser, isUserLoading] = useTransition({ timeoutMs: 1000 });

const getRandomUser = useCallback(() => {
  startLoadingUser(() => {
    getUser(Math.ceil(Math.random() * 1000));
  });
}, []);

return (
  // ...
  <button onClick={getRandomUser} disabled={isUserLoading}>get random user</button>
  <React.Suspense fallback={<div>...loading user</div>}>
    <User userReader={userReader} />
  </React.Suspense>
);
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.

Tags

The Noonification banner

Subscribe to get your daily round-up of top tech stories!