Dieser Blogpost ist auch auf Deutsch verfügbar
Previously on Just Add Code
In the first part of this post, we looked at a simple example of progressive enhancement on a webpage and how to implement it using Stimulus. In this part I will show how to implement the same example with GitHub Catalyst to see where the two libraries are taking different approaches.
As a reminder let us revisit the example we’re implementing. We are using a simple <input>
and, assuming the browser loads our code, allow it to be filled with the text from the clipboard or to transfer the content of the fields to the clipboard with simple button presses. We are using the Clipboard API for this interaction. As not all browsers provide the same level of support for this relatively new API yet, we follow the progressive enhancement idea and only show the functionalities that the browser really supports.
By default the two buttons are hidden through CSS, since we only want them to be visible when they can be used.
The complete example is published on GitHub so you can easily run and inspect it locally.
Catalyst
Catalyst is a typescript-based library that supports you in developing custom elements. In contrast to Stimulus, it is geared towards using the existing W3C standards, so the result is a “real” custom element. Catalyst aims at reducing the amount of boilerplate you have to do yourself to let you concentrate on implementing your functionality.
Catalyst relies heavily on typescript decorators to provide its functionality and reduce the amount of code a developer has to write. However, decorators are still an experimental typescript feature that needs to be specifically enabled for a project. If your project setup makes it impossible to do so, you can still employ Catalyst. The documentation always also shows how the functionality of a decorator can be recreated manually.
The Element
The main part of an implementation using Catalyst is a class that extends the generic HTMLElement
as it is defined by the DOM API. You annotate this class with the @controller
decorator provided by Catalyst in order to trigger the necessary registration in the browser.
The name of our new custom element is derived by Catalyst from the name we gave our class. A trailing Element
(if it is part of the class name) is removed and CamelCasing is turned into kebab-casing. This way we are in full control of the element’s name. In our example the class name EnhancedInputElement
is turned into <enhanced-input>
.
Because we’ve implemented connectedCallback()
, the default callback method from the custom element API that the browser calls whenever a custom element has been attached to the DOM, we should see the message EnhancedInputElement connected
in the browser console.
Binding Existing Markup
The concept of binding HTML child elements to properties in the controller is called “Targets” in Catalyst as well. In contrast to Stimulus however, we don’t get auto-generated properties but need to declare them ourselves. This gives us the additional benefit of being able to use the correct element types from the DOM API and thus get IDE support for their properties and methods. To create the binding we annotate the properties with @target
or @targets
and Catalyst does the necessary search within the scope of our custom element.
You complete the binding by adding data-target
attributes to the HTML elements that should be targeted. The content of this attribute is written in the form {controller-name}.{targetName}
and declares the controller and the property to which it should be bound.
Besides using @target
as shown above, you can also annotate a property with @targets
which will bind an array of elements instead of a single one (internally the decorator performs querySelectorAll
instead of querySelector
). This also means that the same controller/property combination can be used in multiple data-target
attributes.
Implicitly Catalyst scopes the search for targets ‘within the custom element’. This allows us to nest custom elements and allows elements to belong to more than one controller by declaring multiple values in their data-target
attribute.
Having successfully created the binding between our properties in the controller and the HTML elements, we can now adapt our controller to show the buttons that we know the browser provides support for.
Listening to Events
To handle events, we just have to define the necessary methods in our controller. There are no specific constraints on that – we can use all the flexibility that TypeScript provides. In our example we declare both our handler methods as async
in order not to have to deal with promises while using the clipboard API.
This example showcases one of the differences between Catalyst and Stimulus. Because we’re defining all methods and properties ourselves (instead of having some of them generated), we have to make sure that we’ve got concise names and no duplications. But that also leaves us with the freedom to choose more speaking names and call the references to the <button>
elements xxxButton
instead of xxxValue
as Stimulus would.
The ability to bind events from elements in the DOM to methods on a controller by declaration is also called “Actions” in Catalyst. There is only a single attribute used for that: data-action
(much the same as it was for targets). The value of the data-action
attribute follows the scheme {event}:{controller}#{method}
which defines
- which event of the element (
{event}
) we bind (:
) - to which method (
#{method}
) - of which controller (
{controller}
)
If you need to listen to multiple events, you define them in a list separated by spaces data-action="{event-1}"{controller-1}#{method-1} {event-2}:{controller-2}#{method-2}"
. Actions also support the notion of nested custom elements, so the controller
you pick to handle an event can be any controller that is a parent of the element you annotate.
Using Attributes for Configuration
We want to make our “Enhanced Input” component more flexible to use, by allowing control of which functions (copy and paste) are enabled. This should be possible in a declarative way when the custom element is used in an HTML file.
Catalyst supports this with its notion of “Attrs”. Attrs provides another decorator that is aptly named @attr
which you again use to annotate properties in the controller. Once decorated, Catalyst does the binding to HTML attributes for us, looking for attributes following the naming scheme data-{attr-name}
.
Currently you can only use string
, Boolean
and number
as data types for properties annotated with @attr
. One of the advantages of using @attr
is that you will never need to check for null
or undefined
– Catalyst ensures that a default is set in every case. In addition you can use the declaration of your properties in TypeScript to set the default that you want.
Using Boolean
attributes is a little trickier than you would expect. This is because Catalyst is not simply getting the value of the attribute and then trying to interpret it as “true” or “false”. Instead it uses hasAttribute
to check if the attribute is present at all. If it is, the value of the property is always true
, regardless of the contents of the attribute – so Boolean attributes work more like required
on form elements.
We see the result of that in our example, where we add enabled
attributes whenever we want to switch on one of the functions.
If you want to be notified about changes to attributes, there’s no specific mechanism with Catalyst. You can however implement attributeChangedCallback()
, which is defined by the custom elements API. Note that when you do so, you also have to implement a getter for observedAttributes
to announce which attributes you’re interested in. Another catch of this call is that there is no guarantee that the old and the new values actually differ, so if your handling code isn’t idempotent you have to handle that explicitly.
Further Features
Catalyst keeps close to the features offered natively by the web platform and tries to make the use of them easier. Out of that approach follows the support for templates, which allows you to add markup to your custom element that will only be rendered when the controller code is loaded. To do so, you define the markup inside a <template data-shadowroot>
element that by default isn’t rendered by the browser. When the controller starts up it takes the contents of this element and adds them to the element’s shadow root for display.
Applying this functionality to our example looks like this:
The trouble with this simple setup is that now we’re back to rendering an empty element if our JavaScript Code does not run. This is also why the Catalyst developers document this feature as one you should only use cautiously and only for parts that absolutely make no sense without Javascript loaded.
The current implementation also does not allow the mixing of contents written in the <template>
with any that are outside – by default the contents of the <template>
replace all other contents of the element. For us to keep our progressive enhancement we need to double the <input>
element:
One other effect of Catalyst having this feature is that all functionality I’ve shown so far is transparently supporting shadow DOM on elements. You can add your content directly or to the shadow root and Catalyst will make the necessary traversal.
Summary on Catalyst
Using Catalyst feels slick and efficient. Defining your own custom elements is easy and interacting with present markup or adding attributes for some flexibility never needs a lot of code or feels unintuitive (Boolean
attributes notwithstanding). The names of the decorators are verbose enough to tell you what they do, and since all properties are always there you never lose track in the code. I personally also like the short and consistent names of the data-attributes
(as opposed to the need for different names in Stimulus).
Additionally you never feel far away from the actual custom element standard. You don’t have to learn and understand a new and separate API, but you can take your web platform knowledge and apply it, without having to write all the boilerplate.
Having the library based on TypeScript is an additional bonus, as it lets you assign types to all other elements you are working with, so you get proper IDE support for their methods and properties.
The area where Catalyst does not shine as much is whenever you’re looking for support. Just searching for Catalyst on GitHub yields quite a number of other repositories with the same name. On Stack Overflow the tag is taken by a perl web framework. And with the first version released on March 12, 2020, there also isn’t much information out there in the rest of the internet. On the other hand there are only nine source files currently in the repository, each of them pretty readable – so just looking up how things work might be the easiest solution.
Comparing Catalyst and Stimulus
As the example has shown, Catalyst and Stimulus are pretty similar. They follow the same basic approach and use the same names for the same concepts in a number of places (the Catalyst makers openly admit that Stimulus was a big influence for them). So the main differences are in the details.
Catalyst bases itself on a typed language and tries to keep as close to the standard as possible. The main goal is taking over the tedious bits of browser integration to let developers focus on their functionality. At runtime there isn’t any difference between a Catalyst class and a plain custom element. As a developer you have to make sure though that your users' browsers support all necessary standards or supply the necessary polyfills.
Stimulus on the other hand tries to lower the entry barrier as much as possible and offers everything it does as a simple, no-dependencies plain vanilla JavaScript library. It also ensures that support for the necessary standards it bases itself upon (mostly MutationObserver) is present. In this way it also lowers the barrier of entry on the client side.
Feature | Catalyst | Stimulus |
---|---|---|
Language | TypeScript | JavaScript |
Size of the bundle | 9kB |
80kB |
Features | Element binding, Action binding, Attribute binding, Shadow DOM templates | Element binding, Action binding, Attribute binding, Logical CSS class names |
API | W3C Custom Element API | Stimulus API |
Support | GitHub issues, Stack Overflow | Dedicated Hotwire community, GitHub issues |
Both libraries do an excellent job in supporting you in developing custom elements (or something close enough to it to not make a difference). Both ensure that supporting progressive enhancement not only is easy, but also the most obvious path to take. As you probably can see from my comparison I do think that Catalyst is the slightly nicer solution of the two. Just because it tries to stay close to the standard and thus produces know-how that you can use in the long term – besides being the much smaller implementation too.
I want to thank my colleague Robert Glaser for his feedback on earlier versions of this post. The image in the title is by Yancy Min on Unsplash.