Component-based UI is all the rage these days. Did you know that the web has its own native component module that doesn't require the use of any libraries? True story! You can write, publish, and reuse single-file components that will work in any* good browser and in any framework (if that's your bag).
In our last post, we took a look at gluon and how it gives you just enough library support to build components quickly without too much extra.
It's been awhile since our last installment (for reasons which I promise have nothing to do with Breath of the Wild or Hollow Knight), but once you see what we have in store, I think you'll agree it was worth the wait. Today, we're examining our most unusual and (in my humble opinion) interesting web component library to date - Hybrids. Get ready to get functional as we define and compose components from simple objects, and register them only as needed.
As is our custom, we'll get a feeling for Hybrids by reimplementing our running example - a lazy-loading image element. Before we dive in to the practicalities, though, let's briefly check out some of hybrids unique features.
The Big Idea(s)
Unlike all the libraries we've seen so far, Hybrids doesn't deal in typical
custom-element
classes. Instead of extending from HTMLElement
or some superclass thereof,
you define your components in terms of POJOs:
With Hybrids, you define your elements via a library function, instead of using the built-in browser facilities:
import { define, html } from 'hybrids';
export const HelloWorld = {
name: 'World',
render: ({name}) => html`Hello, ${name}!`;
};
define('hello-world', HelloWorld);
That's a fair sight more concise than the vanilla version!
class HelloWorld extends HTMLElement {
constructor() {
super();
this.__name = 'World';
this.attachShadow({mode: 'open'});
this.shadowRoot.appendChild(document.createTextNode('Hello, '));
this.shadowRoot.appendChild(document.createTextNode(this.name));
}
get name() {
return this.__name;
}
set name(v) {
this.__name = v;
this.render();
}
render() {
this.shadowRoot.children[1].data = this.name;
}
}
customElements.define('hello-world', HelloWorld);
What's more, since the element definition is a simple object, it's much easier to modify elements through composition rather than inheritance:
import { HelloWorld } from './hello-world.js';
define('hello-joe', { ...HelloWorld, name: 'Joe' });
But you probably want to write a component that has more to it than "Hello
World". So how do we manage the state of our hybrids components? Let's bring
back our running example <lazy-image>
element for a slightly more dynamic
usage.
Since hybrids has its own highly idiosyncratic approach to custom elements, our
rewrite of <lazy-image>
will involve more than just shuffling a few class
getters, so let's take it piece-by-piece, starting with the element's template.
Templating
We'll define our element's shadow children in a property called (aptly enough)
render
, which is a unary
function that takes the host
element (i.e. the element into which we are rendering) as its argument.
import { dispatch, html } from 'hybrids';
const bubbles = true;
const composed = true;
const detail = { value: true };
const onLoad = host => {
host.loaded = true;
// Dispatch an event that supports Polymer two-way binding.
dispatch(host, 'loaded-changed', { bubbles, composed, detail })
};
const style = html`<style>/*...*/</style>`;
const render = ({alt, src, intersecting, loaded}) => html`
${style}
<div id="placeholder"
class="${{loaded}}"
aria-hidden="${String(!!intersecting)}">
<slot name="placeholder"></slot>
</div>
<img id="image"
class="${{loaded}}"
aria-hidden="${String(!intersecting)}"
src="${intersecting ? src : undefined}"
alt="${alt}"
onload="${onLoad}"
/>
`;
const LazyImage = { render };
define('hybrids-lazy-image', LazyImage);
If you joined us for our posts on lit-element and
Gluon, you'll notice a few similarities and a few glaring
differences to our previous <lazy-image>
implementations.
Like LitElement
and GluonElement
, hybrids use an html
template literal
tag function to generate their template objects. You can interpolate data into
your template's children or their properties, map over arrays with template
returning functions and compose templates together, just like we've seen
previously. Indeed, on the surface hybrids and lit-html look very similar. But
beware - here be dragons. While hybrids' templating system is inspired by
libraries like lit-html
and hyper-html
, it's not the same thing. You can
read more about the specific differences to lit-html at hybrids' templating
system docs. For our
purposes, we need to keep two big differences from lit-html
in mind:
- Bindings are primarily to properties, not attributes. More on that in a bit.
- Event listeners are bound with
on*
syntax (e.g.onclick
,onloaded-changed
) and take the host element, rather than the event, as their first argument, so the function signature is(host: Element, event: Event) => any
.
Since Hybrids emphasizes pure functions, we can extract the onLoad
handler to
the root of the module. Even though its body references the element itself,
there's no this
binding to worry about! We could easily unit test this
handler without instantiating our element at all. Score!
Notice also that we're importing a dispatch
helper from hybrids
to make
firing events a little less verbose.
In our previous implementations, we used a loaded
attribute on the host
element to style the image and placeholder, so why are we using class
on them
now?
Hybrids Prefers Properties to Attributes
Hybrids takes a strongly opinionated stance against the use of attributes in
elements' APIs. Therefore, there's no way to explicitly bind to an attribute of
an element in templates. So how did we bind to the aria-hidden
attribute
above?
When you bind some value bar
to some property foo
(by setting <some-el foo="${bar}">
in the template), Hybrids checks to see if a property with that
name exists on the element's prototype. If it does, hybrids assigns the value
using =
. If, however, that property doesn't exist in the element prototype,
Hybrids sets the attribute using setAttribute
. The only way to guarantee an
attribute binding is to explicitly bind a string as attribute value i.e.
<some-el foo="bar">
or <some-el foo="bar ${baz}">
.
Because of this, it also makes sense in Hybrids-land to not reflect properties to attributes either (In the section on factories we'll discuss an alternative that would let us do this). So instead of keying our styles off of a host attribute, we'll just pass a class and do it that way:
#placeholder ::slotted(*),
#image.loaded {
opacity: 1;
}
#image,
#placeholder.loaded ::slotted(*) {
opacity: 0;
}
Binding to class
and style
Since the class
attribute maps to the classList
property, hybrids handles
that attribute differently. You can pass a string, an array, or an object with
boolean values to a class
binding.
- For strings, hybrids will use
setAttribute
to set theclass
attribute to that string. - For arrays, hybrids will add each array member to the
classList
- For objects, hybrids will add every key which has a truthy value to the
classList
, similar to theclassMap
lit-html directive.
So the following are equivalent:
html`<some-el class="${'foo bar'}"></some-el>`;
html`<some-el class="${['foo', 'bar']}"></some-el>`;
html`<some-el class="${{foo: true, bar: true, baz: false}}"></some-el>`;
Binding to style
is best avoided whenever possible by adding a style tag to
the element's shadow root, but if you need to bind to the element's style
attribute (e.g. you have dynamically updating styles that can't be served by
classes), you can pass in the sort of css-in-js objects that have become de
rigueur in many developer circles:
const styles = {
textDecoration: 'none',
'font-weight': 500,
};
html`<some-el style="${styles}"></some-el>`;
Property Descriptors
If we would define our element with the LazyImage
object above, it wouldn't
be very useful. Hybrids will only call render
when one of the element's
observed properties is set. In order to define those observed properties, we
need to add property descriptors to our object, which are simply keys with any
name other than render
.
const LazyImage = {
alt: '',
src: '',
intersecting: false,
loaded: false,
render;
};
In this example, we're describing each property as simple static scalar values.
In cases like that, Hybrids will initialize our element with those values, then
call render
whenever they are set*. Super
effective, but kinda boring, right? To add our lazy-loading secret-sauce, let's
define a more sophisticated descriptor for the intersecting
property.
Descriptors with real self-confidence are objects that have functions at one or
more of three keys: get
, set
, and connect
. Each of those functions take
host
as their first argument, much like the onLoad
event listener we
defined in our template above.
get
The get
function will run, unsurprisingly, whenever the property is read. You
can set up some logic to compute the property here if you like. Avoid side
effects if you can, but if you need to read the previous value in order to
calculate the next one, you can pass it as the second argument to the function.
This simple example exposes an ISO date string calculated from an element's
day
, month
, and year
properties:
const getDateISO = ({day, month, year}) =>
(new Date(`${year}-${month}-${day}`))
.toISOString();
const DateElementDescriptors = {
day: 1,
month: 1,
year: 2019,
date: { get: getDateISO }
}
Hybrids will check if the current value of the property is different than the
value returned from get
, and if it isn't, it won't run effects (e.g. calling
render
). Reference types like Object and Array are checked with simple
equivalency, so you should use immutable data techniques to ensure your
element
re-renders.
set
If you need to manipulate a value when it is assigned or even (gasp!) perform
side-effects, you can do that with set
, which takes the host
, the new
value, and the last value.
import { targetDate } from './config.js';
const setDateFromString = (host, value, previous) => {
const next = new Date(value);
// reject sets after some target date
if (next.valueOf() < targetDate) return previous;
host.day = next.getDate();
host.month = next.getMonth();
host.year = next.getYear();
return (new Date(value)).toISOString();
}
const DateElementDescriptors = {
day: 1,
month: 1,
year: 2019,
date: {
get: getDateISO,
set: setDateFromString,
}
}
If you omit the set
function, hybrids will automatically add a pass-through
setter (i.e. (_, v) => v
)**.
connect
So far hybrids has done away with classes and this
bindings, but we're not
done yet. The next victims on hybrids' chopping block are lifecycle callbacks.
If there's any work you want to do when your element is created or destroyed,
you can do it on a per-property basis in the connect
function.
Your connect
function takes the host
, the property name, and a function
that will invalidate the cache entry for that property when called. You could
use invalidate
in redux actions, event listeners, promise flows, etc.
connect
is called in connectedCallback
, and should return a function which
will run in disconnectedCallback
.
import { targetDate } from './config.js';
/** connectDate :: (HTMLElement, String, Function) -> Function */
const connectDate = (host, propName, invalidate) => {
const timestamp = new Date(host[propName]).valueOf();
const updateTargetDate = event => {
targetDate = event.target.date;
invalidate();
}
if (timestamp < targetDate)
targetDateForm.addEventListener('submit', updateTargetDate)
return function disconnect() {
targetDateForm.removeEventListener('submit', updateTargetDate);
};
}
const DateElementDescriptors = {
day: 1,
month: 1,
year: 2019,
date: {
get: getDateISO,
set: setDateFromString,
connect: connectDate
}
}
In <hybrids-lazy-image>
, we'll use connect
to set up our intersection observer.
const isIntersecting = ({ isIntersecting }) => isIntersecting;
const LazyImage = {
alt: '',
src: '',
loaded: false,
render,
intersecting: {
connect: (host, propName) => {
const options = { rootMargin: '10px' };
const observerCallback = entries =>
(host[propName] = entries.some(isIntersecting));
const observer = new IntersectionObserver(observerCallback, options);
const disconnect = () => observer.disconnect();
observer.observe(host);
return disconnect;
}
},
};
Factories
It would be tedious to have to write descriptors of the same style for every property, so hybrids recommends the use of 'factories' to abstract away that sort of repetition.
Factories are simply functions that return an object. For our purposes, they are functions that return a property descriptor object. Hybrids comes with some built-in factories, but you can easily define your own.
const constant = x => () => x;
const intersect = (options) => {
if (!('IntersectionObserver' in window)) return constant(true);
return {
connect: (host, propName) => {
const options = { rootMargin: '10px' };
const observerCallback = entries =>
(host[propName] = entries.some(isIntersecting));
const observer = new IntersectionObserver(observerCallback, options);
const disconnect = () => observer.disconnect();
observer.observe(host);
return disconnect;
}
}
}
const LazyImage = {
alt: '',
src: '',
loaded: false,
intersecting: intersect({ rootMargin: '10px' }),
render,
}
In this particular case the win is fairly shallow, we're just black-boxing the descriptor. Factories really shine when you use them to define reusable logic for properties.
For example, even though hybrids strongly recommends against the use of
attributes, we may indeed want to our elements to reflect property values as
attributes, like many built-in elements do, and like the TAG guidelines
recommend.
For those cases, we could write a reflect
factory for our properties:
import { property } from 'hybrids';
export const reflect = (defaultValue, attributeName) => {
// destructure default property behaviours from built-in property factory.
const {get, set, connect} = property(defaultValue);
const set = (host, value, oldValue) => {
host.setAttribute(attributeName, val);
// perform hybrid's default effects.
return set(host, value, oldValue);
};
return { connect, get, set };
};
Factories are one of hybrids' most powerful patterns. You can use them, for
example, to create data provider element decorators that use the hybrids cache
as state store. See the
parent
factory
for examples.
Final Component
import { html, define, dispatch } from 'hybrids';
const style = html`
<style>
:host {
display: block;
position: relative;
}
#image,
#placeholder ::slotted(*) {
position: absolute;
top: 0;
left: 0;
transition:
opacity
var(--lazy-image-fade-duration, 0.3s)
var(--lazy-image-fade-easing, ease);
object-fit: var(--lazy-image-fit, contain);
width: var(--lazy-image-width, 100%);
height: var(--lazy-image-height, 100%);
}
#placeholder ::slotted(*),
#image.loaded {
opacity: 1;
}
#image,
#placeholder.loaded ::slotted(*) {
opacity: 0;
}
</style>
`;
const constant = x => () => x;
const passThroughSetter = (_, v) => v;
const isIntersecting = ({isIntersecting}) => isIntersecting;
const intersect = (options) => {
if (!('IntersectionObserver' in window)) return constant(true);
return {
connect: (host, propName) => {
const observerCallback = entries =>
(host[propName] = entries.some(isIntersecting));
const observer = new IntersectionObserver(observerCallback, options);
const disconnect = () => observer.disconnect();
observer.observe(host);
return disconnect;
}
}
}
const bubbles = true;
const composed = true;
const detail = { value: true };
const onLoad = host => {
host.loaded = true;
// Dispatch an event that supports Polymer two-way binding.
dispatch(host, 'loaded-changed', { bubbles, composed, detail })
};
const render = ({alt, src, intersecting, loaded}) => html`
${style}
<div id="placeholder"
class="${{loaded}}"
aria-hidden="${String(!!intersecting)}">
<slot name="placeholder"></slot>
</div>
<img id="image"
class="${{loaded}}"
aria-hidden="${String(!intersecting)}"
src="${intersecting ? src : undefined}"
alt="${alt}"
onload="${onLoad}"
/>
`;
define('hybrids-lazy-image', {
src: '',
alt: '',
loaded: false,
intersecting: intersect({ rootMargin: '10px' }),
render,
});
Summary
Hybrids is a unique, modern, and opinionated web-component authoring library. It brings enticing features like immutable data patterns, emphasis on pure functions, and easy composability to the table for functionally-minded component authors. With a balanced combination of patterns from the functional-UI world and good-old-fashioned OOP, and leveraging the standards to improve performance and user experience, it's worth giving a shot in your next project.
Pros | Cons |
---|---|
Highly functional APIs emphasizing pure functions and composition | Strong opinions may conflict with your use case or require you to rework patterns from other approaches |
Intensely simple component definitions keep your mind on higher-level concerns | Abstract APIs make dealing with the DOM as-is a drop more cumbersome |
Would you like a one-on-one mentoring session on any of the topics covered here?
Acknowledgements
Special thanks go to Dominik Lubański, Hybrids' author and primary maintainer, for generously donating his time and insight while I was preparing this post, especially for his help refactoring to an idiomatic hybrids style.
*Actually what hybrids does here is generate simple descriptors for you, in order to ensure that property effects are run, etc.
**As of original publication, the behaviour of adding pass-through setters when set
is omitted is not yet released.
2020-10-31: edited vanilla example