DEV Community

Andrei
Andrei

Posted on • Edited on

Practical data fetching with React Suspense that you can use today

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
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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} />;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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}`);
Enter fullscreen mode Exit fullscreen mode

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);
  });
Enter fullscreen mode Exit fullscreen mode

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' });
Enter fullscreen mode Exit fullscreen mode

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;
  }
};
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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>
);
Enter fullscreen mode Exit fullscreen mode

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(/* ... */)
    // ...

  // ...
};
Enter fullscreen mode Exit fullscreen mode

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));
}, []);
Enter fullscreen mode Exit fullscreen mode

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];
};
Enter fullscreen mode Exit fullscreen mode

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);
}, []);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
);
Enter fullscreen mode Exit fullscreen mode

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, []);
Enter fullscreen mode Exit fullscreen mode

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];
};
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
};
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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.

// 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;
}
Enter fullscreen mode Exit fullscreen mode

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>];
Enter fullscreen mode Exit fullscreen mode

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];
};
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
  // ...
);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
);
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (11)

Collapse
 
ruinerwarrior profile image
Rick Nijhuis

Nice article!, one question though, wouldn't it be simpler and maybe more performant if you just passed the promise instead of a method returning a promise? per example:

const promise = (() => { const resp = await fetch("http://url"); return resp.json })()
useAsyncResource(promise);

No need for caching the request because it's already executed and doesn't need to be executed inside the function.

Collapse
 
andreiduca profile image
Andrei

Calling fetch immediately will start the api call immediately. But data needs to be retrieved on demand, and usually with parameters (fetch a user by its id). You cannot create fetch promises for all possible IDs you don't know you're going to use.

Also, we're not caching the fetch requests (i.e. the functions that return the promises), but the generated data reader functions (i.e. the functions that just return the data, or throw the in-progress promises).

Collapse
 
zackdotcomputer profile image
Zack Sheppard

Thanks for writing this post up last year - I'm really enjoying playing around with the library!

I'm trying to wrap my head around Suspense and the thing I can't figure out right now, though, is why you have the pattern of calling your hook outside of the Suspense barrier and then passing the throwing reader function into the child component inside the barrier. This seems more difficult to reason about than the pattern in reactfire where the hook call itself can throw a promise. Is there something I'm missing here about Suspense or is this just a convention difference?

Collapse
 
zackdotcomputer profile image
Zack Sheppard

Ok, I think I figured it out.

The issue is that you need some way to persist the state that is in flight to some point outside of the Suspense boundary. Because the Suspense-aware API requires throwing a value, it interrupts the execution of the component(s) underneath the nearest Suspense component, meaning that any values in their state (including their hooks' states) are wiped out.

reactfire gets away with its design pattern because it has the context provider higher up in the component tree where it can hang the loads in progress.

Collapse
 
siddiqnx profile image
Siddiq Nx

Hi Andrei, nice article! I'm new to react and I just have some questions. You use this in production which means you are using the react concurrent mode, right?

Did you come across any compatibility or any other issues in production? Does your package use-async-resource need react concurrent mode? Can I use your use the package for my project safely?

Collapse
 
andreiduca profile image
Andrei

Hi Siddiq. As explained in the article, the use-async-resource package is working with current versions of React, without concurrent mode.

Collapse
 
allforabit profile image
allforabit

Out of interest do you still use redux observable and do you foresee use cases for it alongside suspense? I.e. for a new project do you think you'd end up bringing redux observable into the mix? Thanks for the article!

Collapse
 
andreiduca profile image
Andrei

In theory, you could hook up your redux (and redux observables) setup with Suspense. In practice, however, I think Suspense lets you move away from that.

Redux (and especially redux observables) requires a lot of under-the-hood boilerplate, and all data flows are created outside your JSX. With Suspense, you can control the flow of data directly from JSX, and handling loading and error states becomes so much simpler. And if you need to use and manage pieces of data in multiple places, a simple context provider can do the job.

Collapse
 
memark profile image
Magnus Markling • Edited

This is a fantastic post! Very well written!

(I just joined dev.to only to be able to follow you!)

You think you'll be maintaining the git repo and npm package going forward?

Collapse
 
andreiduca profile image
Andrei

Thank you! I'm glad you enjoyed the read. :D

Yes, I'll do my best to keep it up to date!

Collapse
 
mihaisavezi profile image
Mihai

Beautifully explained and detailed. Thank you.