How to wrap a Preact component into a Stimulus controller

In this post I'm going to illustrate the following:

  • wrapping a Preact component inside a Stimulus controller
  • loading Preact and the component asynchronously on demand
  • communicating with the wrapped component via JavaScript custom events

This is partly based on work @s_aitchison did last February on Forem. Forem's public website uses Preact and vanilla JavaScript. Some of Forem's Admin views are using Stimulus. This is an example of how to recycle frontend components from one framework to another.

I'm also assuming the reader has some familiarity with both Preact and Stimulus.

Wrapping the component

Yesterday I was working on some Admin interactions and I wanted to reuse Forem's Snackbar component:

How it is implemented inside Preact is not important for our purposes and I haven't checked either, I just know its module exports Snackbar and a function addSnackbarItem to operate it.

As the screenshot shows, it is similar to Material's Snackbar component, as it provides brief messages about app processes at the bottom of the screen.

With that in mind and with the groundwork laid by Suzanne Aitchison on a different component, I wrote the following code:

import { Controller } from 'stimulus';

// Wraps the Preact Snackbar component into a Stimulus controller
export default class SnackbarController extends Controller {
  static targets = ['snackZone'];

  async connect() {
    const [{ h, render }, { Snackbar }] = await Promise.all([
      // eslint-disable-next-line import/no-unresolved
      import('preact'),
      import('Snackbar'),
    ]);

    render(<Snackbar lifespan="3" />, this.snackZoneTarget);
  }

  async disconnect() {
    const { render } = await import('preact');
    render(null, this.snackZoneTarget);
  }

  // Any controller (or vanilla JS) can add an item to the Snackbar by dispatching a custom event.
  // Stimulus needs to listen via this HTML's attribute: data-action="snackbar:add@document->snackbar#addItem"
  async addItem(event) {
    const { message, addCloseButton = false } = event.detail;

    const { addSnackbarItem } = await import('Snackbar');
    addSnackbarItem({ message, addCloseButton });
  }
}

Let's go over it piece by piece.

Defining a container

static targets = ['snackZone'];

Most Preact components need a container to render in. In Stimulus lingo we need to define a "target", which is how the framework calls important HTML elements referenced inside its controller (the main class to organize code in).

This is defined as a regular HTML <div> in the page:

<div data-snackbar-target="snackZone"></div>

Inside the controller, this element can be accessed as this.snackZoneTarget. Stimulus documentation has more information on targets.

(snackZone is just how the Snackbar's container is called inside Forem's frontend code, I kept the name :D)

Mounting and unmounting the component

The Snackbar component, when initialized, doesn't render anything visible to the user. It waits for a message to be added to the stack of disappearing messages that are shown to the user after an action is performed. For this reason, we can use Stimulus lifecycle callbacks to mount it and unmount it.

Stimulus provides two aptly named callbacks, connect() and disconnect(), that we can use to initialize and cleanup our Preact component.

When the Stimulus controller is attached to the page, it will call the connect() method, in our case we take advantage of this by loading Preact and the Snackbar component:

async connect() {
  const [{ h, render }, { Snackbar }] = await Promise.all([
    import('preact'),
    import('Snackbar'),
  ]);

  render(<Snackbar lifespan="3" />, this.snackZoneTarget);
}

Here we accomplish the following:

To be "good citizens" we also want to clean up when the controller is disconnected:

async disconnect() {
  const { render } = await import('preact');
  render(null, this.snackZoneTarget);
}

This destroys Preact's component whenever Stimulus unloads its controller from the page.

Communicating with the component

Now that we know how to embed Preact into Stimulus, how do we send messages? This is where the JavaScript magic lies :-)

Generally, good software design teaches us to avoid coupling components of any type, regardless if we're talking about JavaScript modules, Ruby classes, entire software subsystems and so on.

JavaScript's CustomEvent Web API comes to the rescue.

With it it's possible to lean in the standard pub/sub architecture that JavaScript developers are familiar with: an element listens to an event, handles it with a handler and an action on another element triggers an event. The first element is the subscriber, the element triggering the event is the publisher.

With this is mind: what are Stimulus controllers if not also global event subscribers, reacting to changes?

First we need to tell Stimulus to listen to a custom event:

<body
  data-controller="snackbar"
  data-action="snackbar:add@document->snackbar#addItem">

data-controller="snackbar" attaches Stimulus SnackbarController, defined in the first section of this post, to the <body> of the page.

data-action="snackbar:add@document->snackbar#addItem" instructs the framework to listen to the custom event snackbar:add on window.document and when received to send it to the SnackbarController by invoking its addItem method acting as en event handler.

addItem is defined as:

async addItem(event) {
  const { message, addCloseButton = false } = event.detail;

  const { addSnackbarItem } = await import('Snackbar');
  addSnackbarItem({ message, addCloseButton });
}

The handler extracts, from the event custom payload, the message and a boolean that, if true, will display a button to dismiss the message. It then imports the method addSnackbarItem and invokes it with the correct arguments, to display a message to the user.

The missing piece in our "pub/sub" architecture is the published, that is given us for free via the Web API EventTarget.dispatchEvent method:

document.dispatchEvent(new CustomEvent('snackbar:add', { detail: { message: 'MESSAGE' } }));
document.dispatchEvent(new CustomEvent('snackbar:add', { detail: { message: 'MESSAGE', addCloseButton: false } }));
document.dispatchEvent(new CustomEvent('snackbar:add', { detail: { message: 'MESSAGE', addCloseButton: true } }));

The great advantage is that the publisher doesn't need to inside Stimulus at all, it can be any JavaScript function reacting to an action: the network, the user or any DOM event.

The CustomEvent interface is straightforward and flexible enough that can be used to create more advanced patterns like the, now defunct, Vue Events API which provided a global event bus in the page, out of scope for this post.

Demo

Conclusion

I hope this showed you a strategy of reuse when you're presented with multiple frameworks that have to interact with each other on a page.

14