Skip to content

andreiduca/use-async-resource

Repository files navigation

useAsyncResource - data fetching hook for React Suspense

Convert any function that returns a Promise into a data reader function. The data reader can then be consumed by a "suspendable" React component.

The hook also returns an updater handler that triggers new api calls. The handler refreshes the data reader with each call.

✨ Basic usage

yarn add use-async-resource

then:

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>;
}

Data Reader and Refresh handler

The useAsyncResource hook returns a pair:

  • the data reader function, which returns the expected result, or throws if the result is not yet available;
  • a refresh handler to fetch new data with new parameters.

The returned data reader userReader is a function that returns the user object if the api call completed successfully.

If the api call has not finished, the data reader function throws the promise, which is caught by the React.Suspense boundary. Suspense will retry to render the child component until it's successful, meaning the promised completed, the data is available, and the data reader doesn't throw anymore.

If the api call fails with an error, that error is thrown, and the ErrorBoundary component will catch it.

The refresh handler is identical with the original wrapped function, except it doesn't return anything - it only triggers new api calls. The data is retrievable with the data reader function.

Notice the returned items are a pair, so you can name them whatever you want, using the array destructuring:

const [userReader, getUser] = useAsyncResource(fetchUser, id);

const [postsReader, getPosts] = useAsyncResource(fetchPosts, category);

const [commentsReader, getComments] = useAsyncResource(fetchPostComments, postId, { orderBy: "date", order: "desc" });

Api functions that don't accept parameters

If the api function doesn't accept any parameters, just pass an empty array as the second argument:

const fetchToggles = () => fetch('/path/to/global/toggles').then(res => res.json());

// in App.jsx
const [toggles] = useAsyncResource(fetchToggles, []);

Just like before, the api call is immediately invoked and the toggles data reader can be passed to a suspendable child component.

🦥 Lazy initialization

All of the above examples are eagerly initialized, meaning the data starts fetching as soon as the useAsyncResource is called. But in some cases you would want to start fetching data only after a user interaction.

To lazily initialize the data reader, just pass the api function without any parameters:

const [userReader, getUserDetails] = useAsyncResource(fetchUserDetails);

Then use the refresh handler to start fetching data when needed:

const [selectedUserId, setUserId] = React.useState();

const selectUserHandler = React.useCallback((userId) => {
  setUserId(userId);
  getUserDetails(userId); // 👈 call the refresh handler to trigger new api calls
}, []);

return (
  <>
    <UsersList onUserItemClick={selectUserHandler} />
    {selectedUserId && (
      <React.Suspense>
        <UserDetails userReader={userReader} />
      </React.Suspense>
    )}
  </>
);

The only difference between a lazy data reader and an eagerly initialized one is that the lazy data reader can also return undefined if the data fetching hasn't stared yet.

Be aware of this difference when consuming the data in the child component:

function UserDetails({ userReader }) {
  const userData = userReader();
  // 👆 this may be `undefined` at first, so we need to check for it

  if (userData === undefined) {
    return null;
  }

  return <div>{userData.username} - {userData.email}</div>
}

📦 Resource caching

All resources are cached, so subsequent calls with the same parameters for the same api function return the same resource, and don't trigger new, identical api calls.

This is useful for many reasons. First, it means you don't have to necessarily initialize the data reader in a parent component. You only have to wrap the child component in a Suspense boundary:

function App() {
  return (
    <React.Suspense fallback="loading posts">
      <Posts />
    </React.Suspense>
  );
}

function Posts(props) {
  // as usual, initialize the data reader and start fetching the posts
  const [postsReader] = useAsyncResource(fetchPosts, []);

  // now read the posts and render a list
  const postsList = postsReader();

  return postsList.map(post => <Post post={post} />);
}

This still works as you'd expect, even if the App component re-renders for any other reason, before, during or even after the posts have loaded. Because the data reader gets cached, only the first initialization will trigger an api call.

This also means you can write code like this, without having to think about deduplicating requests for the same user id:

function App() {
  // just like before, start fetching posts
  const [postsReader] = useAsyncResource(fetchPosts, []);

  return (
    <React.Suspense fallback="loading posts">
      <Posts dataReader={postsReader} />
    </React.Suspense>
  );
}


function Posts(props) {
  // read the posts and render a list
  const postsList = props.dataReader();

  return postsList.map(post => <Post post={post} />);
}


function Post(props) {
  // start fetching users for each individual post
  const [userReader] = useAsyncResource(fetchUser, props.post.authorId);
  // 👉 notice we don't need to deduplicate the user resource for potentially identical author ids

  return (
    <article>
      <h1>{props.post.title}</h1>
      <React.Suspense fallback="loading author">
        <Author dataReader={userReader} />
      </React.Suspense>
      <p>{props.post.body}</p>
    </article>
  );
}


function Author(props) {
  // get the user object as usual
  const user = props.dataReader();

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

🚚 Preloading resources

When you know a resource will be consumed by a child component, you can preload it ahead of time. This is useful in cases such as lazy loaded components, or when trying to predict a user's intent.

// 👉 import the `preloadResource` helper
import { useAsyncResource, preloadResource } from 'use-async-resource';

// a lazy-loaded React component
const PostsList = React.lazy(() => import('./PostsListComponent'));

// some api function
const fetchUserPosts = (userId) => fetch(`/path/to/get/user/${userId}/posts`).then(res => res.json())


function UserProfile(props) {
  const [showPostsList, toggleList] = React.useState(false);

  return (
    <>
      <h1>{props.user.name}</h1>
      <button
        // show the list on button click
        onClick={() => toggleList(true)}
        // 👉 we can preload the resource as soon as the user
        // shows any intent of interacting with the button 
        onMouseOver={() => preloadResource(fetchUserPosts, props.user.id)}
      >
        show user posts
      </button>

      {showPostsList && (
        // this child will suspend if either:
        // - the `PostList` component code is not yet loaded
        // - or the data reader inside it is not yet ready
        // 👉 notice we're not initializing any resource to pass it to the child component
        <React.Suspense fallback="...loading posts">
          <PostsList userId={props.user.id} />
        </React.Suspense>  
      )}
    </>
  );
}

// in PostsListComponent.tsx
function PostsList(props) {
  // 👉 instead, we initialize the data reader inside the child component directly
  const [posts] = useAsyncResource(fetchUserPosts, props.userId);

  // ✨ because we preloaded it in the parent with the same `userId` parameter,
  // it will get initialized with that cached version

  // also, the outer React.Suspense boundary in the parent will take care of rendering the fallback
  return (
    <ul>
      {posts().map(post => <li><Post post={post} /></li>)}
    </ul>
  );
}

In the above example, even if the child component loads faster than the data, re-rendering it multiple times until the data is ready is ok, because every time the data reader will be initialized from the same cached version. No api call will ever be triggered from the child component, because that happened in the parent when the user hovered the button.

At the same time, if the data is ready before the code loads, it will be available immediately when the child component will render for the first time.

Clearing caches

Finally, you can manually clear caches by using the resourceCache helper.

import { useAsyncResource, resourceCache } from 'use-async-resource';

// ...

const [latestPosts, getPosts] = useAsyncResource(fetchLatestPosts, []);

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

In this case, we're clearing the entire cache for the fetchLatestPosts api function. But you can also use the delete() method with parameters, so you only delete the cache for those specific ones:

const [user, getUser] = useAsyncResource(fetchUser, id);

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

Data modifiers

When consumed, the data reader can take an optional argument: a function to modify the data. This function receives the original data as a parameter, and the transformation logic is up to you.

const userDisplayName = userDataReader(user => `${user.firstName} ${user.lastName}`);

File resource helpers

Suspense is not just about fetching data in a declarative way, but about fetching resources in general, including images and scripts.

The included fileResource helper will turn a URL string into a resource "data reader" function, but it will load a resource instead of data. When the resource finishes loading, the "data reader" function will return the URL you passed in. Until then, it will throw a Promise, so Suspense can render a fallback.

Here's an example for an image resource:

import { useAsyncResource, fileResource } from 'use-async-resource';

function Author({ user }) {
  // initialize the image "data reader"
  const [userImageReader] = useAsyncResource(fileResource.image, user.profilePicUrl);
    
  return (
    <article>
      {/* render a fallback until the image is downloaded */}
      <React.Suspense fallback={<SomeImgPlaceholder />}>
        {/* pass the resource "data reader" to a suspendable component */}
        <ProfilePhoto resource={userImageReader} />
      </React.Suspense>
      <h1>{user.name}</h1>
      <h2>{user.bio}</h2>
    </article>
  );
}

function ProfilePhoto(props) {
  // just read back the URL and use it in an `img` tag when the image is ready
  const imageSrc = props.resource();

  return <img src={imageSrc} />;
}

Using the fileResource to load external scripts is just as easy:

function App() {
  const [jq] = useAsyncResource(fileResource.script, 'https://code.jquery.com/jquery-3.4.1.slim.min.js');

  return (
    <React.Suspense fallback="jQuery loading...">
      <JQComponent jQueryResource={jq} />
    </React.Suspense>
  );
}

function JQComponent(props) {
  const jQ = props.jQueryResource();

  // jQuery should be available and you can do something with it
  return <div>jQuery version: {window.jQuery.fn.jquery}</div>
}

Notice we don’t do anything with the const jQ, but we still need to call props.jQueryResource() so it can throw, rendering the fallback until the library is fully loaded on the page.

📘 TypeScript support

The useAsyncResource hook infers all types from the api function passed in. The arguments it accepts after the api function are exactly the parameters of the original api function.

const fetchUser = (userId: number): Promise<UserType> => fetch('...');

const [wrongUserReader] = useAsyncResource(fetchUser, "some", "string", "params"); // 🚨 TS will complain about this
const [correctUserReader] = useAsyncResource(fetchUser, 1); // 👌 just right
const [lazyUserReader] = useAsyncResource(fetchUser); // 🦥 also ok, but lazily initialized

The only exception is the api function without parameters:

  • the hook doesn't accept any other arguments than the api function, meaning it's lazily initialized;
  • or it accepts a single extra argument, an empty array, when we want the resource to start loading immediately.
const [lazyToggles] = useAsyncResource(fetchToggles); // 🦥 ok, but lazily initialized
const [eagerToggles] = useAsyncResource(fetchToggles, []); // 🚀 ok, starts fetching immediately
const [wrongToggles] = useAsyncResource(fetchToggles, "some", "params"); // 🚨 TS will complain about this

Type inference for the data reader

The data reader will return exactly the type the original api function returns as a Promise.

const fetchUser = (userId: number): Promise<UserType> => fetch('...');

const [userReader] = useAsyncResource(fetchUser, 1);

userReader is inferred as () => UserType, meaning a function that returns a UserType object.

If the resource is lazily initialized, the userReader can also return undefined:

const [userReader] = useAsyncResource(fetchUser);

Here, userReader is inferred as () => (UserType | undefined), meaning a function that returns either a UserType object, or undefined.

Type inference for the refresh handler

Not just the data reader types are inferred, but also the arguments of the refresh handler:

const fetchUser = (userId: number): Promise<UserType> => fetch('...');

const [userReader, getNewUser] = useAsyncResource(fetchUser, 1);

The getNewUser handler is inferred as (userId: number) => void, meaning a function that takes a numeric argument userId, but doesn't return anything.

Remember: the return type of the handler is always void, because the handler only kicks off new data api calls. The data is still retrievable via the data reader function.

Default Suspense and ErrorBoundary wrappers

Again, a component consuming a data reader needs to be wrapped in both a React.Suspense boundary and a custom ErrorBoundary.

For convenience, you can use the bundled AsyncResourceContent that provides both:

import { useAsyncResource, AsyncResourceContent } from 'use-async-resource';

// ...

<AsyncResourceContent
  fallback="loading your data..."
  errorMessage="Some generic message when bad things happen"
>
  <SomeComponent consuming={aDataReader} />
</AsyncResourceContent>

The fallback can be a string or a React component.

The errorMessage can be either a string, a React component, or a function that takes the thrown error as an argument and returns a string or a React component.

<AsyncResourceContent
  fallback={<Spinner />}
  errorMessage={(e: CustomErrorType) => <span style={{ color: 'red' }}>{e.message}</span>}
>
  <SomeComponent consuming={aDataReader} />
</AsyncResourceContent>

Custom Error Boundary

Optionally, you can pass a custom error boundary component to be used instead of the default one:

class MyCustomErrorBoundary extends React.Component { ... }

// ...

<AsyncResourceContent
  // ...
  errorComponent={MyCustomErrorBoundary}
  errorMessage={/* optional error message */}
>
  <SomeComponent consuming={aDataReader} />
</AsyncResourceContent>

If you also pass the errorMessage prop, your custom error boundary will receive it as a prop.