I. try...catch at UI Layer
First, let's state the conclusion: Suspense is like try...catch, determining whether UI is safe:
try {
// Once there's something not ready
} catch {
// Immediately enter catch block, go to fallback
}
So, how to define safety?
Think about it: if a component's code hasn't finished loading, and we try to render it, it's obviously unsafe. So, narrowly speaking, we can consider components whose code is ready as safe, including synchronous components and loaded asynchronous components (React.lazy), for example:
// Synchronous component, safe
import OtherComponent from './OtherComponent';
// Asynchronous component, unsafe
const AnotherComponent = React.lazy(() => import('./AnotherComponent'));
// ...After AnotherComponent code finishes loading
// Loaded asynchronous component, safe
AnotherComponent
Error Boundary
There's a similar thing called Error Boundary, also a form of try...catch at UI layer, where safety is defined as component code execution without JavaScript Error:
Error boundaries are React components that catch JavaScript errors anywhere in their child component tree, log those errors, and display a fallback UI instead of the component tree that crashed.
We find these two definitions don't conflict. In fact, Suspense and Error Boundary can indeed coexist, such as using Error Boundary to catch asynchronous component loading errors:
If the other module fails to load (for example, due to network failure), it will trigger an error. You can handle these errors to show a nice user experience and manage recovery with Error Boundaries.
For example:
import MyErrorBoundary from './MyErrorBoundary';
const OtherComponent = React.lazy(() => import('./OtherComponent'));
const AnotherComponent = React.lazy(() => import('./AnotherComponent'));
const MyComponent = () => (
<div>
<MyErrorBoundary>
<Suspense fallback={<div>Loading...</div>}>
<section>
<OtherComponent />
<AnotherComponent />
</section>
</Suspense>
</MyErrorBoundary>
</div>
);
II. Build a Suspense Yourself
The 5 lines of code at the beginning might be interesting, but not clear enough. Let's continue filling in:
function Suspense(props) {
const { children, fallback } = props;
try {
// Once there's something not ready
React.Children.forEach(children, function() {
assertReady(this);
});
} catch {
// Immediately enter catch block, go to fallback
return fallback;
}
return children;
}
assertReady is an assertion that throws Error for unsafe components:
import { isLazy } from "react-is";
function assertReady(element) {
// Lazy components that haven't finished loading are unsafe
if (isLazy(element) && element.type._status !== 1) {
throw new Error('Not ready yet.');
}
}
P.S. react-is is used to distinguish Lazy components, and _status indicates the loading state of Lazy components. See React Suspense | Specific Implementation for details.
Let's try it out:
function App() {
return (<>
<Suspense fallback={<div>loading...</div>}>
<p>Hello, there.</p>
</Suspense>
<Suspense fallback={<div>loading...</div>}>
<LazyComponent />
</Suspense>
<Suspense fallback={<div>loading...</div>}>
<ReadyLazyComponent />
</Suspense>
<Suspense fallback={<div>loading...</div>}>
<p>Hello, there.</p>
<LazyComponent />
<ReadyLazyComponent />
</Suspense>
</>);
}
Corresponding UI content is:
Hello, there.
loading...
ready lazy component.
loading...
First render result meets expectations. As for the subsequent update process (replacing loading with actual content after component finishes loading), it belongs more to the category of Lazy component rendering mechanism, not much related to Suspense. Won't expand here. If interested, refer to React Suspense | Specific Implementation.
P.S. Among them, ReadyLazyComponent construction has a small trick:
const ReadyLazyComponent = React.lazy(() =>
// Simulate import('path/to/SomeOtherComponent.js')
Promise.resolve({
default: () => {
return <p>ready lazy component.</p>;
}
})
);
// Render Lazy Component once to trigger its loading, making it ready
const rootElement = document.getElementById("root");
// Only used to preload lazy component, ignore Warning about missing outer Suspense
ReactDOM.createRoot(rootElement).render(<ReadyLazyComponent />);
setTimeout(() => {
// After above rendering finishes, ReadyLazyComponent is truly ready
});
Because Lazy Component only loads when truly needed to render (so-called lazy), render it once first, then it's ready when used again.
III. Analogy to try...catch
As described above, the correspondence between Suspense and try...catch is:
-
Suspense: corresponds to
try -
fallback: corresponds to
catch -
Lazy Component not yet loaded: corresponds to
Error
Due to similarity in principle, many characteristics of Suspense can be easily understood by analogy to try...catch, for example:
-
Nearest fallback: After
Erroris thrown, look upward for the nearesttry's correspondingcatch -
Fallback if there's unready component: In a large
tryblock, as long as there's oneError, immediately entercatch
So, for a group of components wrapped by Suspense, either all are displayed (including possibly contained fallback content), or none are displayed (instead display that Suspense's fallback). Understanding this point is especially important for mastering Suspense.
Performance Impact
As in the example above:
<Suspense fallback={<div>loading...</div>}>
<p>Hello, there.</p>
<LazyComponent />
<ReadyLazyComponent />
</Suspense>
Render result is loading..., because when processing LazyComponent, Suspense fallback is triggered. Whether already processed Hello, there., or not yet processed ReadyLazyComponent, neither can be displayed. So, there are 3 problems:
-
Collateral damage: One not-yet-loaded Lazy Component can prevent many components before it that could display immediately from displaying
-
Blocking rendering: Not-yet-loaded Lazy Component will block rendering flow, blocking rendering of all components after it under nearest Suspense ancestor, causing serial waiting
So, like using try...catch, abusing Suspense will also cause (UI layer) performance impact. Although technically wrapping the entire application in top-level Suspense can indeed provide fallback for all Lazy Components:
<Suspense fallback={<div>global loading...</div>}>
<App />
</Suspense>
But must clearly realize the consequences of doing so.
Structural Characteristics
Suspense, like try...catch, eliminates conditional judgments by providing a fixed structure:
try {
// If Error appears
} catch {
// Then enter catch
}
Branch logic is solidified into syntax structure. Suspense is similar:
<Suspense fallback={ /* Then enter fallback */ }>
{ /* If unready Lazy component appears */ }
</Suspense>
The benefit of doing this is no need for conditional judgments in code, thus not depending on local state, we can easily adjust its scope:
<Suspense fallback={<div>loading...</div>}>
<p>Hello, there.</p>
<LazyComponent />
<ReadyLazyComponent />
</Suspense>
Change to:
<p>Hello, there.</p>
<Suspense fallback={<div>loading...</div>}>
<LazyComponent />
</Suspense>
<ReadyLazyComponent />
Almost no change cost before and after, even easier than adjusting try...catch boundaries (because no need to consider variable scope). This is very meaningful for harmlessly adjusting loading granularity and sequence:
Suspense lets us change the granularity of our loading states and orchestrate their sequencing without invasive changes to our code.
IV. Online Demo
All important examples involved in the article are in the Demo project (with detailed comments):
V. Summary
Suspense is like try...catch at UI layer, but what it catches is not exceptions, but components not yet loaded.
Of course, Error Boundary is also. The two catch their own things without conflict.
No comments yet. Be the first to share your thoughts.