I was reading Addy Osmani and Hassan Djirdeh's book Building Large Scale Web Apps. (Which, by the way, I can definitely recommend.) In it they cover all the ways to make a React app sing at scale. The chapter on Modularity was especially interesting to me, because JavaScript modules are a common approach to modularity in both React and vanilla web code.
In that chapter on Modularity there was one particular topic that caught my eye,
and it was the use of lazy()
and Suspense
, paired with an ErrorBoundary
.
These are the primitives that React gives us to asynchronously load UI components and their data on-demand while showing a fallback UI,
and replace the UI with an error message when something goes wrong.
If you're not familiar, here's a good overview page.
It was at that time that I was visited by the imp of the perverse, which posed to me a simple challenge: can you bring React's lazy loading primitives to vanilla web components? To be clear, there are many ways to load web components lazily. This is well-trodden territory. What wasn't out there was a straight port of lazy, suspense and error boundary. The idea would not let me go. So here goes nothing.
Lazy
The idea and execution of React's lazy is simple. Whenever you want to use a component in your code,
but you don't want to actually fetch its code yet until it needs to be rendered, wrap it using the lazy()
function:
const MarkdownPreview = lazy(() => import('./MarkdownPreview.js'));
React will automatically "suspend" rendering when it first bumps into this lazy component until the component has loaded, and then continue automatically.
This works in React because the markup of a component only looks like HTML,
but is actually JavaScript in disguise, better known as JSX.
With web components however, the markup that the component is used in is actually HTML,
where there is no import()
and no calling of functions.
That means our vanilla lazy cannot be a JavaScript function, but instead it must be an HTML custom element:
<x-lazy><x-hello-world></x-hello-world></x-lazy>
The basic setup is simple, when the lazy component is added to the DOM,
we'll scan for children that have a '-' in the name and therefore are custom elements,
see if they're not yet defined, and load and define them if so.
By using display: contents
we can avoid having the <x-lazy>
impact layout.
To actually load the element, we'll have to first find the JS file to import, and then run its register function.
By having the function that calls customElements.define
as the default export by convention the problem is reduced to finding the path to the JS file.
The following code uses a heuristic that assumes components are in a ./components/
subfolder of the current document
and follow a consistent file naming scheme:
One could get a lot more creative however, and for example use an import map to map module names to files. This I leave as an exercise for the reader.
Suspense
While the lazy component is loading, we can't show it yet. This is true for custom elements just as much as for React.
That means we need a wrapper component that will show a fallback UI as long as any components in its subtree are loading,
the <x-suspense>
component. This starts out as a tale of two slots. When the suspense element is loading it shows the fallback, otherwise the content.
The trick now is, how to we get loading = true
to happen?
In Plain Vanilla's applications page I showed how a React context can be simulated using the element.closest()
API.
We can use the same mechanism to create a generic API that will let our suspense wait on a promise to complete.
Suspense.waitFor
will call the nearest ancestor <x-suspense>
to a given element, and give it a set of promises that it should wait on.
This API can then be called from our <x-lazy>
component.
Note that #loadElement
returns a promise that completes when the custom element is loaded or fails to load.
The nice thing about the promise-based approach is that we can give it any promise, just like we would with React's suspense.
For example, when loading data in a custom element that is in the suspense's subtree, we can call the exact same API:
Suspense.waitFor(this, fetch(url).then(...))
Error boundary
Up to this point, we've been assuming everything always works. This is Spartasoftware, it will never "always work".
What we need is a graceful way to intercept failed promises that are monitored by the suspense,
and show an error message instead. That is the role that React's error boundary plays.
The approach is similar to suspense:
And the code is also quite similar to suspense:
Similar to suspense, this has an API ErrorBoundary.showError()
that can be called
from anywhere inside the error boundary's subtree to show an error that occurs.
The suspense component is then modified to call this API when it bumps into a rejected promise.
To hide the error, the reset()
method can be called on the error boundary element.
Finally, the error
setter will set the error as a property or attribute
on all children in the error slot, which enables customizing the error message's behavior based on the error object's properties
by creating a custom <x-error-message>
component.
Conclusion
Finally, we can bring all of this together in a single example, that combines lazy, suspense, error boundary, a customized error message, and a lazy-loaded hello-world component.
For the complete example's code, as well as the lazy, suspense and error-boundary components, check out the sweet-suspense repo on Github.