paint-brush
Guide to React Suspense: From Code Splitting to Data Fetchingby@olegwock
2,065 reads
2,065 reads

Guide to React Suspense: From Code Splitting to Data Fetching

by OlegWockOctober 30th, 2023
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

React Suspense, introduced in React 16.6, has come a long way, evolving from code splitting to data fetching. It provides the ability to suspend rendering, enabling new optimizations and improved server-side rendering. The article discusses how Suspense works, its use cases, benefits, and introduces the use hook for data fetching.
featured image - Guide to React Suspense: From Code Splitting to Data Fetching
OlegWock HackerNoon profile picture


Suspense was introduced in React 16.6 (and that was 5 years ago), yet for a long time its sole purpose was just code splitting. You could use it to wire your own data fetching throwing promises here and there, but that was pretty much it. Now, in React 18, Suspense got a significant upgrade with support for SSR streaming and selective hydration. And recently, a new hook (called use), which significantly simplifies using Suspense for data fetching, was documented on the new React docs.


Though <Suspense /> was a part of React for 5 years, Suspense for data fetching is still not considered production-ready by the React team and is not recommended for use in production. Moreover, use hook, which we'll cover today, is not exposed as a part of the stable React release. To try it out yourself, you'll need to use the Canary or experimental React channel. You can find more info about those channels here.


What it does

At a conceptual level, Suspense is a boundary, a bit like an Error boundary. Though it's not used to isolate errors in a subtree (instead of letting it bubble to root and crash your app), it's used to isolate a subtree that is loading at the moment of render. If React, while rendering an app, stumbles upon a component that isn't ready to be rendered yet, it will walk upwards to the closest Suspense boundary, suspend its children subtree (this means it will hide the subtree but not unmount it), and display the provided fallback. Once the component that caused suspense finishes loading, the whole tree will be re-rendered and displayed again.

But let's take a look at the details: how components might not be ready to be rendered, what 'suspending' a subtree really means, and what practical use cases Suspense has.


Suspense behaves a bit differently in concurrent mode. We won't explore it in this article, but I plan to cover it in the next article dedicated to concurrent features of React.


What causes subtree suspension

The first and most known way to suspend is to render a lazy component. A lazy component is a component returned by React.lazy and it's used to move certain components out of the initial bundle, thus reducing its size and load time. When you first render such a component, React will find the closest <Suspense /> and suspend its subtree. When a component is loaded, the subtree will be re-rendered. If a load fails, the error will be propagated to the closest error boundary.


Another way is to use Suspense-enabled data fetching libraries (like Relay) or call use with a promise directly (or throw a promise if use is unavailable). In this case, React will suspend while the promise is pending and then re-render when the promise resolves. As with lazy, errors will be propagated to the closest error boundary. More about use later in the article.


The last case is related to server-side rendering. If, during SSR, one of your components inside the Suspense boundary throws an error, it won't be propagated to the closest error boundary (even if there is one closer than the suspense boundary). Instead, React will render fallback from Suspense and send it to the client. On the client, React will try to render the component again. If it succeeds, fallback will be replaced with a rendered component. If the component throws an error again, it will be treated as usual — propagated to the closest error boundary (or crash your app if there is none).


What happens with the component

As I said, when React suspends a subtree, React doesn't unmount it, and the current state of the component is preserved. Instead, React just 'hides' the suspended subtree. Under the hood, Suspense uses the new Offscreen component (available only in the experimental channel), which assigns style="display: none !important;" to elements and fires cleanup for layout effects, so you can stop subscriptions that might depend on DOM elements.


If you want to dive into the details of Suspense implementation, I really recommend this article by JSer.


Benefits

With Suspense, React introduces the state of 'loading' as a first-class citizen into the React world. Before, you used the component state to indicate data loading status. You could have something like this in your code:


const { data, isLoading, error } = useData();


Based on the value of those properties, you rendered the loading spinner, error screen, or actual view with data. React couldn't know if your component was loading some data or not. With Suspense, you now let React know when a component is loading something, which in turn allows React to do some useful optimizations.


Firstly, this simplifies the data-fetching code. You don't need to check for isLoading or error, all code after use is guaranteed to have data ready to use.

// This:
const OldComponent = () => {
    const { transactions, isLoading, error } = useTransatcions();

    // If you need to use transactions in hooks, it becomes 
    // even more cumbersome because you need to always check 
    // if data isn't undefined (and you obviously can't move 
    // hooks below ifs because it will make them conditional)

    if (isLoading) {
        return (<Spinner />);
    }

    if (error) {
        return (<Error />);
    }

    // Finally you can render actual component!
    return (<TransactionsTable transactions={transactions} />);
};

// becomes this
const SuspensePowered = () => {
    const transactions = useTransatcionsSuspense();
    // Use any hooks you want here
    // transactions are populated with data at this point
    return (<TransactionsTable transactions={transactions} />);
}


Another benefit is improved SSR. I already mentioned that Suspense helps to recover from errors while rendering an app on the server, but it doesn't end there. If you use Suspense-enabled data fetching libraries, this enables react-dom/server to stream HTML as your components render. React will emit fallback for suspended boundaries and continue rendering other components. Once data is fetched, React will render the subtree and emit HTML along with a small inline script that will replace the previously rendered fallback. So you get progressive loading even before the main bundle with React is loaded into the client browser, and now one big component (which loads a lot of data) won't slow down the rendering of the whole app.


Another benefit Suspense brings to SSR is selective hydration. Now, client-side React doesn't need to wait for all the code to be loaded before hydrating. Previously, lazy components weren't really compatible with SSR, you had to choose either to have SSR and a large bundle or no SSR but a smaller bundle. Suspense allows React to hydrate the app part by part once all the components from the respective Suspense subtree are loaded.


Dan describes both optimizations extremely well and in great detail in this post.


Use hook 101

use hook was recently documented, and it means that sooner or later we'll see it in stable React. For now, though, it's only in canary and experimental channels. So you still have some time to master it ;)


While use is definitely a hook and you can't use it outside of function components, it's a bit special: you can use it in conditional statements and loops.


This hook is used to read the value of a resource. Currently, it works only with context and promises, but that might change in the future. For reading context, it pretty much mirrors useContext hook. But if you pass it a promise, it will suspend the component if the promise isn't resolved yet and return data from the promise if it's fulfilled. Any errors will be propagated to the error boundary (or you can add .catch to your promise and return an alternative value in case of failure).


Here is how you use this hook:

import { use, Suspense } from 'react';

const onlineUsersPromise = getOnlineUsers();

const Users = () => {
    const users = use(onlineUsersPromise);
    //            ^^ this will suspend if onlineUsersPromise 
    //               isn't resolved when component is rendering

    return (<div>
        <h2>Users online</h2>
        <ul>
            {users.map(user => (<li>{user}</li>))}
        </ul>
    </div>);
};

const App = () => {
    return (<Suspense fallback="Loading...">
        <Users />
    </Suspense>);
}


As you can see, we create the promise outside the component, this doesn't look very convenient, does it? You could try to create a promise directly in the component, like this:

const users = use(getOnlineUsers());


But this won't work because, on each render of a component, we'll create a new promise and just spam our server with a bunch of requests. If you're making your own data fetching solution with Suspense, you'll need to write some glue code, you can't use use out of the box. At least you will need to memorize the promise somehow.


Data fetching with use

So let's write that glue code and make our own data-fetching hook around use. We won't reinvent the wheel and will make the API of our hook similar to SWR or react-query. You supply a string, which acts as a global cache key, and a function, which returns a promise. If there is no record with this key in the cache, we create a promise and save it to the cache; otherwise, we reuse a promise from the cache.

function useData<T>(key: string, func: () => Promise<T>): T;


Unfortunately, if your component will suspend on the first render (before it's mounted), you can't have cache local for each component instance (using useMemo or useRef) because after the first suspension any refs and memoized values will be discarded. So we'll need some kind of global cache. For the sake of simplicity, we'll just use global Map, but you can of course use context to localize cache to a part of your app.

const dataCache = new Map<string, Promise<any>>();
const useData = <T,>(key: string, load: () => Promise<T>) => {
    let promise = dataCache.get(key);
    if (!promise) {
        promise = load();
        dataCache.set(key, promise);
    }

    const data = use(promise as Promise<T>);

    return data;
};


The code above obviously misses a lot of features from big boy data fetching libraries. For example, revalidation or better cache control. But it does its job. It's used as follows:

const WinesList = ({ type }: { type: string }) => {
    const wines = useData(`wines-${type}`, () => getWines(type));

    return (<ul key={type}>
        {wines.map(wine => {
            return (<li key={wine.id}>
                {wine.wine} from {wine.location}
            </li>)
        })}
    </ul>);
};


And that's all for today! The serious part of the article ends here, but I have a few more experimental tricks with use for you. This is the part where we do reinvent the wheel.


Own lazy components

With use hook, you can make your own implementation of React.lazy. The original lazy implementation doesn't use use under the hood, but the concept is the same.

const myLazy = <P extends JSX.IntrinsicAttributes, R = unknown>(loader: () => Promise<ComponentType<P>>) => {
    let promise: Promise<ComponentType<P>> | undefined = undefined;
    return forwardRef((props: P, ref) => {
        if (!promise) {
            promise = loader();
        }
        const Component = use(promise);

        return (<Component {...props} ref={ref} />)
    });
};

const LazyCallout = myLazy(() => {
    return import('@blog/components/Callout').then(m => m.Callout)
});


We use closure to create a state hidden from outside code but available to all instances of the lazy component we return (like LazyCallout). The load of the actual component will be triggered on the first render of the first instance of the lazy component, later calls will reuse cached promise.


Poor man's <Offscreen />

You can already use <Offscreen />, but that will require using React from the experimental channel. If that is not something you would like but you still want to have Offscreen, we can mimic it with Suspense. Implementation is a bit lengthy, so bear with me.

// This function returns promise which can be resolved from outside
const createPromiseWithResolve = () => {
    let resolve: () => void = () => { };
    const promise = new Promise<void>((_resolve) => {
        resolve = _resolve;
    });

    return { promise, resolve };
};


function usePrevious<T>(value: T) {
    const ref = useRef<T | undefined>(undefined);
    useEffect(() => {
        ref.current = value;
    });
    return ref.current;
}

const Offscreen = ({ children, mode }: { 
    children: ReactNode, 
    mode: 'hidden' | 'visible' 
}) => {
    const previousMode = usePrevious(mode);
    const promiseRecordRef = useRef<
        ReturnType<typeof createPromiseWithResolve> | undefined
    >(undefined);

    if (previousMode !== mode || !promiseRecordRef.current) {
        promiseRecordRef.current?.resolve();
        promiseRecordRef.current = mode === 'hidden' ?
            createPromiseWithResolve()
            : { promise: Promise.resolve(), resolve: () => { } };
    }
    return (<Suspense>
        <OffscreenInner promise={promiseRecordRef.current.promise}>
            {children}
        </OffscreenInner>
    </Suspense>);
};

const OffscreenInner = ({ children, promise }: { 
    children: ReactNode, 
    promise: Promise<void> 
}) => {
    use(promise);
    return (<>{children}</>)
};

// --- Usage:
const ControlledOffscreen = () => {
    const [showRed, setShowRed] = useState(true);
    const [showGreen, setShowGreen] = useState(true);

    return (<div>
        <div>
            <Button onClick={() => setShowRed(true)}>
                Show red
            </Button>
            <Button onClick={() => setShowRed(false)}>
                Hide red
            </Button>
            <Button onClick={() => setShowGreen(true)}>
                Show green
            </Button>
            <Button onClick={() => setShowGreen(false)}>
                Hide green
            </Button>
        </div>

        <div>
            <Offscreen mode={showRed ? 'visible' : 'hidden'}>
                <div style={{ color: 'red' }}>Red</div>
            </Offscreen>
            <Offscreen mode={showGreen ? 'visible' : 'hidden'}>
                <div style={{ color: 'green' }}>Green</div>
            </Offscreen>
        </div>
    </div>);
};


The main work is done inside the <Offscreen /> component. On the first render, we create a new promise: depending on the mode prop it will either be already resolved or pending. We then supply this promise to OffscreenInner. Since we render a suspense boundary inside our component, we need another component inside this boundary to suspend. If we call use in our parent component, it will propagate to the suspense boundary outside the component (if there is one), and this is not the behavior we need. So we use an additional component just to call use there. We keep track of the previous value of mode and if it changes, we resolve the current promise and generate a new one.


Offscreen will be useful if you want to keep some components mounted but not display any of their DOM nodes. For example, if you're writing a router, you might want to keep routes mounted to improve the speed of navigation. Of course, you can just flip style based on property: <div style={{display: mode === 'visible' ? 'block' : 'none'}} />, but with this approach, React won't clean up layout effects, which might bring some issues.


This approach has one significant downside (except its sheer kludginess). If a promise isn't resolved by itself (just like in our case) it significantly slows down your SSR since the server will wait for some time before triggering timeout and then try to resolve the promise on the client instead, which will make your site feel slower.


This is something that might be solved by the postpone API, but it's also only in the experimental channel.


Anyways, this is really it for today. Let me know if you have any questions, and stay tuned for my guide on Concurrent React!


Also published here.


Lead photo by Filip Zrnzević from Unsplash