Hajime, the duck guy

Sunday, March 26, 2023, by Hajime Yamasaki Vukelic

Poor man's islands architecture using 300 bytes of vanilla code.

Sometimes you don't need much to enjoy yourself on a nice island surrounded by the ocean of static content.

The islands architecture is all the rave now. Some believe it's the answer to all questions and a solution to all issues. Right now, I'm working on a project of learning and comparing six of the more talked-about frameworks (I'll write about that once I'm done), and one of them is an implementation of the islands architecture. Since I'm new to that topic, I decided to read about it. As part of my effort to understand the concept, I wrote a simple component islands loader, and I'll share it with you here.

If you're new to the islands architecture, seeing the loader code may help you understand what it's about and how it works. Many authors make it sounds like it's rocket surgery, but it's really a simple (and effective) concept that shouldn't really lose its value just because it can be explained simply.

Islands of interactive UI bits

The idea behind the islands architecture is that you have a page with mixed static and highly interactive content, but the code for the interactive bits is loaded ad-hoc instead of all at once, speeding up the time-to-interactive.

As is common nowadays, developers use lots and lots of code for the interactive content, and the bundle sizes are getting close to half a megabyte on average. SSR alone does not address this issue because the bundle of JavaScript still needs to be shipped client-side to make the SSR'd content interactive again.

The islands architecture splits the page into blocks of interactive and non-interactive content, uses SSR to stitch them back together. Unlike micro-frontends or more traditional web apps, it leaves the code for the interactive components separate. The client-side loader code then loads the code for the interactive components as needed.

The "as needed" part is defined variably by different authors ranging from "visible in the viewport" to "having a chance of interaction". Regardless, the key takeaway is that it generally only works well if the page has lots of components that are not used immediately. In a truly desktop-application-style UI where everything is pretty much in the viewport, the benefits of using the islands architecture are slim to none.

Still, modern designs are really more like web pages than real apps, so there are plenty of scenarios where the islands architecture can shine.

Islands on the cheap

Sure there are frameworks for making islands-enabled pages. They do a lot more than just enable you to do the islands stuff. But if you just want to implement the islands architecture and you handle SSR using a more traditional SSR method (e.g., PHP, C#, Java, Python, NodeJS, WordPress, etc.) or even just a static site generator, the islands can actually be had for relatively cheap without any frameworks.

Let's write the loader script first. I create a file called loader.js and link it to the HTML like so:


<head>
    <!-- .... -->
    <script type="module" src="loader.js"></script>
</head>

We start off by creating an intersection observer:

var io = new IntersectionObserver(function (es) {
    for (var e of es) if (e.isIntersecting) {
        io.unobserve(e.target)
        import(e.targetg.dataset.module ||
               `/components/${e.target.tagName.toLowerCase()}.js`)
    }
})

This is the key part of the loader. It monitors registered nodes and as soon as they are within the viewport, it unhooks the observer and loads the code for the component.

The module to load is determined based on the data-module attribute, and if such an attribute is not specified (or has no value), we fall back on using the element's tag name.

The observer stops observing elements for which it has loaded the code.

The second bit of code scans the document for custom elements and adds them to the observer:

for (var $ of document.querySelectorAll('*'))
    if ($.tagName.includes('-')) io.observe($)

I used the * selector to have the script go through just about any element on the page. We could technically explicitly mark islands by using a custom attribute like island and select [island]. That would probably be a bit more efficient. This is good enough as a proof of concept.

It should be noted that the current version of the intersection observer does not track visibility. Firefox and Safari have still not adopted the v2, so we'll have to wait a bit before we can actually say "Don't load if not visible" (e.g., obscured by some other element or has opacity of 0).

Here's all of it in one piece. Around 300 bytes without any minification or gzipping.

var io = new IntersectionObserver(function (es) {
    for (var e of es) if (e.isIntersecting) {
        io.unobserve(e.target)
        import(e.targetg.dataset.module ||
               `/components/${e.target.tagName.toLowerCase()}.js`)
    }
})
for (var $ of document.querySelectorAll('*'))
    if ($.tagName.includes('-')) io.observe($)

Creating components

We have used a convention for storing components in a components/ directory. Unless we want to customize the module path, the module name always matches the name of the custom element.

// components/test-island.js
customElements.define('test-island', class extends HTMLElement {
    connectedCallback() {
        this.innerHTML = '<p>Hello, island!</p>'

        // Or maybe:
        //
        // ReactDOM.createRoot(this).render(<App/>)
    }
})

The use of custom elements is not strictly intended for the actual component code. It's more about making it easier to acquire the node to which we want to attach the component logic. You can technically now render a React component into this node if you'd like.

The point is that, by doing it this way, we don't need to care about where on the page the component island is located, or how many times it is used, nor do we need to add initialization logic to the loader. It keeps things simpler and lighter.

The component script can also load its own stylesheets. This can be achieved by simply creating a <link> tag from within the script:

document.head.append(Object.assign(document.createElement('link'), {
    rel: 'stylesheet',
    media: 'screen',
    href: 'components/test-island.css',
}))

Alternatively, this logic can be moved to the loader. Moving the logic into the loader will be simpler in some cases because the component then does not have to worry about where the CSS is stored.

The SSR code or your static HTML files can now include the component anywhere in the page as a custom element:

<test-island>
    Some placeholder
</test-island>

The placeholder

The placeholder inside the custom element can simply be an indication that something will load (e.g., a spinner).

Or, even better, it could be a fully functional basic version of the functionality that doesn't require JavaScript and can work with the SSR server to facilitate the essential features. This is called progressive enhancement, and it's a natural fit for the SSR-enabled islands architecture. If the user has JavaScript disabled, the placeholder content is never replaced, so might as well make it work, too.

The beauty of the progressive enhancement approach is that, in a team setting, the basic version and the enhanced version can be worked on by different people or even different teams as they can be completely separate and independent.

Truly decoupled

Speaking of separate and independent, one advantage of the islands architecture is that the components can be developed and deployed completely separately from the main application. Different components can even have individual independent tech stacks. This is particularly useful for teams where people can't develop entire applications on their own for various reasons.

What about shared state or inter-component communication? Even when components need to share state or communicate with each other, this can be achieved using browser's built-in mechanisms such as sessionStorage, event buses, service workers, etc. This keeps the components truly decoupled and independent.

Posted in Programming tips
Back to top