Essentials about Suspense and Error Boundaries

We go through the main concepts of Suspense and ErrorBoundaries and highlight what they have in common and what the difference is. Also, we find out about such patterns in React for fetching data: Fetch-On-Render and Render-As-You-Fetch and why we’re talking about it in the context of Suspense.

Vladimir Topolev
Numatic Ventures

--

Photo by Lautaro Andreani on Unsplash

Content:
- Fetch-On-Render pattern
- How React behaves when React component throws Error, Promise
- React.lazy and Suspense
- Render-As-You-Fetch pattern

Do you know how to fetch data in the React component? Definitely, you know, since you do it every day as a ReactJS developer. I even know what pattern comes to your mind first. It should look like this:

const ContainerComponent = ({userId}) => {
const [user, setUser] = useState(null);

useEffect(() => {
fetchUserInfo(userId)
.then((fetchedUser) => setUser(fetchedUser))
}, []);

if(user === null) {
return <p>Loading data</p>
}

return <UserInfo user={user}>
}

Was I right?

We all love patterns, and it’s our vocabulary that helps us convey ideas and understand each other. Do you know the name of this one? You may come up with your own name—for example, the pattern of fetching data in React components. You are right, but it is not precise.

This pattern is called Fetch-On-Render.

Fetch-On-Render pattern

It is a self-explanatory name:

👉 The Fetch-On-Render pattern assumes that the data retrieval is initiated only after the component has been rendered on the screen.

It is a well-known and straightforward pattern, and there’s nothing to add.

Do you know any other approaches to fetch data? Even if you don’t know, you may assume there’s a way initially to fetch data and only after that the React mount component in the tree. This pattern is called Render-As-You-Fetch.

But before the detailed explanation of this approach, let’s discuss some questions. Answering this question is a crucial point to understanding the Render-As-You-Fetch pattern more deeply. As a bonus, we implement React.lazy function on our own, and it would be a great question in any interview.

How React behaves when React component throws Error, Promise

❓ What will happen if React component throws an Error.

If your application throws an error during rendering, React will remove its UI from the screen. To prevent this, any production application has a wrapper with an error boundary that would show that something wrong happened to the user:


export class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}



static getDerivedStateFromError(error) {
return { hasError: true };
}

render() {
if (this.state.hasError) {
return <h1>Something went wrong.</h1>;
}

return this.props.children;
}
}

Then you can wrap any part of your component tree with it:

<ErrorBoundary>
<Component />
</ErrorBoundary>

You may have a bunch of error boundary components on one page, and each error boundary will be responsible for catching any error that may be thrown among any own children React component and show a fallback instead:

<ErrorBoundary>
<Component1>
<ErrorBoundary>
<Component2>
<Component3>
</ErrorBoundary>
</ErrorBoundary>

I believe that you already know it. Do you remember that all those expressions are valid when you throw an error in Javascript:

throw new Error('Error message');
throw 12;
throw true;
throw { message: 'Error message' };
throw new Promise((res) => res);

All expressions, excluding the last one — working as you would expect — the nearest Error Boundaries catch these exceptions.

❓ What will happen if React component throws an Promise outside event handlers or hooks?

You may try to run this code:

const ComponentWithThrownPromise = () => {
console.log('Rendering ComponentThrownError');
throw new Promise((res) =>
setTimeout(() => {
console.log('resolved');
res();
}, 5000)
);
return <div>ComponentWithThrownPromise</div>
};

const App = () => (
<ErrorBoundary>
<p>Any other component</p>
<ComponentWithThrownPromise />
</ErrorBoundary>
);

You will probably be surprised, but you will see an empty screen, and Error Boundary does not catch this error. If you have a look at the console, then you will see that React tries to rerender components every 5 seconds after the promise is resolved. Let me provide some explanation here:

What is it about?

👉 Throwing Promise in a React Component intiates a special mechanism that enables components to “suspend” rendering while waiting for the asynchronous operation to be completed.

I believe that you’ve already heard about Suspense components in React. Let’s wrap our ComponentWithError with Suspense component:

const ComponentWithError = () => {
// skipped for brevity
};

const App = () => (
<ErrorBoundary>
<p>Any other component</p>
<Suspense fallback={<p>Components is suspended</p>}>
<ComponentWithError />
</Suspense>
</ErrorBoundary>
);

In this case, you will see text wrapped in p element and the fallback from the Suspense component.

It means that Suspense works in the same way as Error Boundaries, but the difference is that Suspense catches the thrown Promises from components and ErrorBoundary others. Also, pay attention that React tries to rerender the component that throws the Promise only when this Promise is resolved.

But our example is useless since we will see a fallback from the Suspense component forever: in this case, each time React tries to rerender the component, it throws a new promise that will be resolved in 5 seconds, and it is going to repeat again and again.

Let’s rewrite it in a way that the component throws Promise only during the first rendering:

const throwPromise = () => {
let status = 'pending';
const promise = new Promise((res) =>
setTimeout(() => {
console.log('resolved');
status = 'resolved';
res();
}, 5000)
);
return () => {
if (status === 'pending') {
throw promise;
}
};
};

const runThrowPromiseOnce = throwPromise();

const ComponentThrownError = () => {
console.log('Rendering ComponentThrownError');
runThrowPromiseOnce();
return <div>ComponentWithThrownPromise</div>;
};

Here, we create a special util function that returns another function — runner. The runner has access via closure to a promise that is created only once during the creation of this runner (since we create it outside of the component). The runner function throws the promise when it is not resolved yet, but as soon as the promise is resolved — it does nothing.

Well, when you run the code, you will see the fallback for 5 sec, and after that, React successfully changes it into the usual component since the component does not throw Promise anymore.

Let’s go along with this “suspense” mechanism and how it should work. If any React component throws a Promise, the nearest Suspense component catches this promise. It shows a fallback instead of rendering any own child components (the principle is the same as in Error Boundaries). Since we throw Promise, the nearest Suspense component waits until the thrown promise is resolved, and after that, React tries to rerender this component again.

The pseudo-code of what react does under the hook looks like this:

renderLeaf = (Component, props, state) => {
try {
// try to render Component
return Component(props, state);
} catch(reason) {
// if during rendering thrown any issue and
// it is a Promise, we run Suspense mechanism
if (reason instanceof Promise) {
reactUtils.showNearestSuspenseFallbackFor(Component);
reason.then(() => {
// after the thrown promise is resolved
// react tries to rerender component again
renderLeaf(Component, props, state)
});
return;
}

// if during rendering thrown any other issue but not Promise
// we run Error Boundaries mechanism
reactUtils.showNearestErrorBoundaryFallbackFor(Component);
}
}

React.lazy and Suspense

Do you remember when theSuspense component was released in React? It’s used for code-splitting, and it works together with the React.lazy function. It is a High-Order Component where you need to pass an import of dynamically uploading component:

const DynamicComponent = React.lazy(() => import('./Component'));

After that, you may use this Component together with Suspense:

const App = () => (
<Suspense fallback={<p>Components is suspended</p>}>
<DynamicComponent />
</Suspense>
);

While requesting the chunk of code for this component, you should see a fallback from Suspense component.

By the way, the mechanism of code-splitting is not a part of React. Bundlers like Webpack, Rollup, and others support this mechanism:

// ./Component.ts
export default Component = () => {}


// importing component in any other file
import('./Component')
.then(({default}) => {
// default is an exported component
})

If I were an interviewer, I would ask a candidate to try to implement React.lazy function. You’ve already got enough knowledge to do it by yourself. Take some time and see the example of implementation below:

let NEXT_ID= 0;
const loaded = {};

export const lazy = modulePathResolver => {
const id = NEXT_ID++;
return props => {
const LoadedComponent = loaded[id];
if (LoadedComponent) {
return <LoadedComponent {...props} />;
}
throw modulePathResolver().then(lazyModule => {
const Component = lazyModule.default;
loaded[id] = Component;
});
};
};

At first, this component throws a Promise that is supposed to be resolved when the component is uploaded. React catches this promise in the nearest Suspense component and waits until this promise is resolved. During the ”suspended” time, React renders a fallback of this Suspense component. When the promise is resolved, React will try to rerender this component again, but in this case, the React component returns an already uploaded component from a cache — loaded variable

Render-As-You-Fetch Pattern

Finally, we’re on the last step of our trip. We have enough knowledge to implement it.

First, let’s create a function createPromise that is responsible for creating a promise if it has not been created previously (in other words — if it is not contained in the cache). During promise creation, we should also extend it and add extra fields status , value and error to the instance of created promise. Status may be pending/resolved/error. When the promise is resolved, we keep the returned value in apromise.value field. If the promise is rejected, then we keep the thrown error in apromise.error field:


const cache = new Map();

const createPromise = (promiseFactory, key) => {
if (cache.get(key)) {
return cache.get(key);
}

let promise = promiseFactory();
promise.status = 'pending';

promise
.then((data) => {
promise.value = key;
promise.status = 'resolved';
})
.catch((e) => {
promise.error = e;
promise.status = 'rejected';
});

cache.set(key, promise);

return promise;
};

And let’s create a special react hook that sets the promise in a state. Also, based on promise status, we do the following:
- pending — throw targeted promise instance — it initiates a “suspended” mechanism in React;
- resolved — return value from promise — this value may be used in the React component;
- error — throw error — in this case ErrorBoundary comes into play.

const usePromiseForSuspense = (promiseFactory, key) => {
const [promise, setPromise] = useState(createPromise(promiseFactory, key));

useEffect(() => {
setPromise(createPromise(promiseFactory, key));
}, [key]);

if (promise.status === 'pending') {
throw promise;
}

if (promise.status === 'rejected') {
throw promise.error;
}

if (promise.status === 'resolved') {
return {
value: promise.value,
reset: () => {
cache.delete(key);
setPromise(createPromise(promiseFactory, key));
},
};
}
};

By the way, we also implement a mechanism to reset the promise, and in the React component, we may initiate this process again. This reset function just clears a cache with a recently created promise based on the key (tag). This tag may be considered as a URL, for example.

Let’s use this hook in the react component:

const ComponentWithSuspense = () => {
console.log('Rendering ComponentWithSuspense component');

const [url, setUrl] = useState('url1');

const { value, reset } = usePromiseForSuspense(
() =>
new Promise((res) =>
setTimeout(() => {
res();
}, 5000)
),
'url1'
);

return (
<div>
{value} <button onClick={() => reset()}>Reset</button>
</div>
);
};
export default ComponentWithSuspense;

It’s time to test:

Conclusions:

React has a special mechanism called “suspend” on the client side. It is triggered by throwing a Promise in a React Component outside of hooks. This mechanism was initially used for code-splitting but can also now be used for data fetching. Normally, we use the Fetch-On-Render pattern for data fetching, but the Suspense mechanism allows us to use the Render-As-You-Fetch pattern as well.

--

--

Vladimir Topolev
Numatic Ventures

Addicted Fullstack JS engineer. Love ReactJS and everything related to animation