TheĀ SANDBOX

Suspense

This is an example implementation of React Suspense using Error Boundaries (introduced in React 14) to emulate the behaviour of Suspense Boundaries. In essense, Suspense works by interupting rendering by throwing a Promise. Here, the Error Boundary is used to catch the promise, await the result, and rerender the sub-tree. This requires a very specific version of React where Error Boundaries are implemented, but Suspense Boundaries are not in any capacity. Here, we use 16.5.2.

The fetchCache is the crux of the whole system. When it is called with a URL for the first time, a Promise is created and thrown which will resolve to the Response. Subsequent calls before the Promise resolves will re-throw the same Promise.

function Component() {
  // throws Promise<Data>
  const data = fetchCache.get("https://example.com/data.json")
  ...
}

This Promise is caught at the SuspenseBoundary. Remember, this is just a React Error Boundary. The SuspenseBoundary suspends, the fallback Element is show, and the Promise is awaited. When it resolves, two things happen:

First, the fetchCache updates its cache with the response data. This actually happens as the Promise resolves.

Second, the SuspenseBoundary unsuspends, triggering a rerender of its sub-tree. This time, fetchCache.get("...") resolves the data synchronously and Component is able to fully render. At this point, any children of Component that have their own Suspense functionality can begin their data fetching operations in the same manner.

function Component() {
  // returns Data
  const data = fetchCache.get("https://example.com/data.json")
  ...
}

Caveats

Because this relies on state, SSR is not supported. Furthermore, for nested components using Suspense, data is fetched in a waterfall fashion, leading to UIs that load piece-by-piece, slowly.

Code

In the preview panel, click on an example post to see its lazily-loaded comments and other meta information. Try clicking out of and back into a post before it finishes loading. Try clicking out of and back into a post after it has loaded.

suspense.jsx
suspense_app.jsx
fetchCache.js
skeleton.jsx
suspense.css
index.html
class SuspenseBoundary extends React.Component {
    inFlight = undefined;

    state = { fallback: false, hasError: false };

    componentDidUpdate(prevProps) {
        if (prevProps.children !== this.props.children) {
            this.setState({ fallback: false });
        }
    }

    componentDidCatch(maybeError) {
        if (maybeError instanceof Promise) {
            if (this.inFlight === maybeError) {
                // we're already waiting on this effect
                return;
            }

            this.setState({
                fallback: true,
                hasError: false,
            });

            this.inFlight = maybeError;

            this.inFlight.finally(() => {
                this.setState({ fallback: false });
                this.inFlight = undefined;
            });
        } else {
            this.setState({ hasError: true });
        }
    }

    render() {
        if (this.state.fallback) {
            return this.props.fallback;
        } else if (this.state.hasError) {
            return null;
        }

        return this.props.children;
    }
}
function User({ userId }) {
    const data = fetchCache.get(
        "https://jsonplaceholder.typicode.com/users/" + userId,
    );

    return (
        <div>
            <span>{data.name}</span> - <span>{data.email}</span>
        </div>
    );
}

function CommentsList({ postId }) {
    const data = fetchCache.get(
        "https://jsonplaceholder.typicode.com/comments?postId=" + postId,
    );

    return data.map((e, i) => (
        <div class="comment" key={i} onClick={() => onClickItem(e.id)}>
            <h2>{e.name}</h2>
            <p>{e.body}</p>
        </div>
    ));
}

function Post({ postId }) {
    const data = fetchCache.get(
        "https://jsonplaceholder.typicode.com/posts/" + postId,
    );

    return (
        <div class="post">
            <h2>{data.title}</h2>
            <p>{data.body}</p>

            <SuspenseBoundary fallback={<UserSkeleton />}>
                <User userId={data.userId} />
            </SuspenseBoundary>

            <h3>Comments</h3>
            <SuspenseBoundary fallback={<PostSkeletonList length={10} />}>
                <CommentsList postId={postId} />
            </SuspenseBoundary>
        </div>
    );
}

function PostList({ activePostId, onClickItem }) {
    const data = fetchCache
        .get("https://jsonplaceholder.typicode.com/posts")
        .slice(0, 5);

    return (
        <div class="vertical-list">
            {data.map((e, i) => (
                <div
                    class={[activePostId === e.id && "active", "post"]
                        .filter(Boolean)
                        .join(" ")}
                    onClick={() => onClickItem(e.id)}
                >
                    <h2>{e.title}</h2>
                    <p>{e.body}</p>
                </div>
            ))}
        </div>
    );
}

class App extends React.Component {
    state = { activePostId: undefined };

    onClickItem = (e) => {
        this.setState({ activePostId: e.currentTarget.dataset.postId });
    };

    render() {
        return (
            <div className="container">
                <div className="listcontainer">
                    <SuspenseBoundary
                        fallback={<PostSkeletonList length={5} />}
                    >
                        <PostList
                            activePostId={this.state.activePostId}
                            onClickItem={(postId) =>
                                this.setState({ activePostId: postId })
                            }
                        />
                    </SuspenseBoundary>
                </div>
                {this.state.activePostId && (
                    <div className="activePost">
                        <SuspenseBoundary fallback={<PostSkeleton />}>
                            <Post postId={this.state.activePostId} />
                        </SuspenseBoundary>
                    </div>
                )}
            </div>
        );
    }
}

ReactDOM.render(<App />, document.querySelector("#root"));
const realConsoleError = console.error;
console.error = () => {
    /* shut up */
};

const fetchCache = {
    cache: {},
    inFlight: {},

    get(url) {
        if (this.inFlight[url]) {
            throw this.inFlight[url];
        }

        if (this.cache[url]) {
            return this.cache[url];
        }

        this.cache[url] = undefined;
        this.inFlight[url] = Promise.all([
            // emulate a slow connection
            new Promise((r) => setTimeout(r, 2000)),
            fetch(url),
        ])
            .then(([, r]) => r)
            .then((r) => r.json())
            .then((j) => {
                this.inFlight[url] = undefined;
                this.cache[url] = j;
            })
            .catch(() => {
                /* do nothing */
            });

        throw this.inFlight[url];
    },
};
function UserSkeleton() {
    return (
        <div className="skeleton">
            <span /> - <span />
        </div>
    );
}

function PostSkeleton() {
    return (
        <div className="skeleton">
            <h2 />
            <p />
        </div>
    );
}

function PostSkeletonList({ length }) {
    return (
        <div class="vertical-list">
            {new Array(length).fill(undefined).map((_, i) => (
                <PostSkeleton key={i} />
            ))}
        </div>
    );
}
html,
body {
    width: 100%;
    height: 100%;
}

* {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

.container {
    display: flex;
    flex-direction: row;
    padding: 12px;
    gap: 12px;
}

.container > * {
    flex: 1;
    width: 50%;
}

.vertical-list,
.skeleton,
.comment,
.post {
    display: flex;
    flex-direction: column;
    gap: 12px;
}

.vertical-list .post {
    text-decoration: underline;
    cursor: pointer;
    color: white;
}

.vertical-list .post.active {
    padding-left: 12px;
    border-left: 3px solid white;
}

.listcontainer h2 {
    width: 100%;
    font-size: 24px;
    white-space: nowrap;
    text-overflow: ellipsis;
    overflow-x: hidden;
}

@keyframes pulse {
    0% {
        opacity: 1;
    }
    50% {
        opacity: 0.5;
    }
}

div.skeleton h2,
div.skeleton p {
    display: block;
    width: 100%;
}

div.skeleton span {
    display: inline-block;
    width: 100px;
}

div.skeleton h2,
div.skeleton p,
div.skeleton span {
    background-color: lightgrey;
    border-radius: 2px;
    animation: infinite 1s pulse;
}

div.skeleton h2 {
    height: 24px;
}

div.skeleton p {
    height: 50px;
}
<div id="root"></div>