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.
Let's Build Web Components! Part 6: Gluon
Benny Powers 🇮🇱🇨🇦 ・ Oct 28 '18 ・ 6 min read
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 |
hybridsjs / hybrids
Extraordinary JavaScript UI framework with unique declarative and functional architecture
An extraordinary JavaScript framework for creating client-side web applications, UI components libraries, or single web components with unique mixed declarative and functional architecture
hybrids provides a complete set of tools for the web platform - everything without external dependencies:
- Component Model based on plain objects and pure functions
- Global State Management with external storages, offline caching, relations, and more
- App-like Routing based on the graph structure of views
- Localization with automatic translation of the templates content
- Layout Engine making UI layouts development much faster
- Hot Module Replacement support and other DX features
Documentation
The project documentation is available at the hybrids.js.org site.
Quick Look
Component Model
It's based on plain objects and pure functions*, still using the Web Components API under the hood:
import { html, define } from "hybrids";
function increaseCount(host) {
host.count += 1;
}
export default define({
…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
Top comments (4)
nice
IKR!
Hybrids is pretty cool. What do you think about Haunted?
Hadn't heard of it 'till now, and had to do some googling to find it:
matthewp / haunted
React's Hooks API implemented for web components 👻
Haunted🦇 🎃
React's Hooks API but for standard web components and hyperHTML or lit-html.
Getting started
A starter app is available on codesandbox and also can be cloned from this repo. This app gives you the basics of how to…
Looks worth a glance, maybe I'll write a post on it.
Thanks for the tip!