For all of you who don't know yet, hybrids is a JavaScript library for creating web components. It uses a unique hybrid approach based on plain objects and pure functions. This article is the fourth in the series about the core features of the library.
So far we covered how to give up on classes and switch to the full power of plain objects. We have learned more about the cache mechanism, and we have discovered how the latest changes made the library even faster and easier to use.
However, let's be honest - the templates are the heart of UI components. Also, they usually take the biggest part of the component definition. In hybrids, you've got the ultimate freedom to choose the tool for this job. It's super easy to adopt any UI library, that produces the DOM and use it with render factory as a template engine (here you have two examples using React and lit-html). However, the built-in template engine can give you important benefits over the other options.
Inspiration
The foremost inspiration for the built-in template engine was the lit-html library, but the implementation is different and it follows its own conventions. The main objective was to use tagged template literals syntax to create the DOM and update dynamic parts leaving static content untouched.
At the time when the engine was created the lit-html was in the very early development stage. After the first major version, the syntax has changed dramatically. I wanted to create a library, which has no external dependencies, so there will be no problem with possible breaking changes. Also, the hybrids library brings some unique patterns, which I knew that the template engine should follow. For these reasons, I decided to try to build it myself. What we can say about the result?
Closest to the roots
One of the main differences is how it tries to predict user needs, so you don't have to learn special DSL or additional syntax for passing properties or attaching event listeners - just use pure HTML and expressions:
html`
<button onclick="${increaseCount}" disabled="${disabled}">
Count: ${count}
</button>
`
The built-in elements follow the pattern, where attributes are reflected with corresponding property values. The <input>
element goes even further and its value
can be updated only by the property. The template engine uses element definition and chooses if it should pass values to the property, or eventually use the attribute (as a fallback when the property is not found in the prototype chain). Event listeners are attached by the on*
attributes, where the second part is used as the type of the event. Even though attributes are not case-sensitive, the template engine uses the exact name defined in the template, so it is possible to set properties like this:
html`
<div innerHTML="${mySafeHTML}" onMyCustom-event="${myListener}"></div>
`
There are two exceptions for the built-ins - class
and style
attributes. As they reflect different properties, the engine accepts a variety of values passed to expressions and it passes them to the right DOM APIs.
html`
<button class="${{ primary: true, active: false }}">...</button>
`
You may think, that it can't work for all complicated use cases, but give it a try - after all the templates are just about elements composition, passing data and receiving feedback by event listeners!
A deeper explanation of the concept can be found in the Properties & Attributes section of the hybrids library documentation.
Let the host be with you
The most unique feature is related to one of the core patterns of the hybrids library. Instead of using this
syntax, the descriptors' methods take a host element as a first argument. This simple shift has a great impact on data flow. The definition of the function is decoupled from the execution context, so those methods are pure functions (except obvious template side effects). A similar idea was implemented in the template engine.
The render factory requires that the passed function will return UpdateFunction(host, target)
, which takes two arguments - the host and target element. The template engine html
not only produces an UpdateFunction
but also supports it as a nested template used in the expression. It will be clearer if we look at the following example:
// We still have access to DOM `event` in the second argument
function doSomething(host, event) {
host.name = 'Did it!';
}
const MyElement = {
name: 'Do it!',
render: ({ name }) => html`
<div id="content">
<button onclick="${doSomething}">${name}</button>
</div>
`,
};
Because the result of the template engine has access to the host element, we can use it for event listeners. Instead of passing only the event object, the first argument is the host. Do you see how this makes a huge difference? The user actions usually change the state of the component, not the element, with which the interaction was taken.
If we would not have direct access to the host element, we would have to create a dynamic function inside of the template:
const MyElement = {
name: 'Do it!',
render: (host) => {
const { name } = host;
return html`
<div id="content">
<button onclick="${() => { host.name = 'Did it!'; }}">${name}</button>
</div>
`,
};
In the above example, we no longer can use destructuring at the level of the arguments - we need a reference to the host. Also, the side effect became an internal part of the template.
It has two important implications. The function will be generated each time the template updates. Also, unit testing is much harder. Before, with access to the host, it was possible to write simple unit tests for the doSomething()
function. It wasn't linked to the template at all nor to DOM elements - it was just a function, which takes an object and updates its name
property. It's not possible with the callback defined inside of the template.
What about the nested templates? The expressions support passing UpdateFuncion
, which html
returns. Because of that, it's possible to create separate functions producing partial templates, even outside of the main component definition. If they use event listeners, the callbacks will still have the correct access to the component host element:
// It can be safely defined in a separate file, like `partials.js`
export default function buttonPartial(fn, name) {
return html`
<button onclick="${fn}">${name}</button>
`;
};
// And then imported
import buttonPartial from './partials';
// It still works, as the host is what we expect to be
function doSomething(host, event) {
host.name = 'Yes, you did it!';
}
const MyElement = {
name: 'Do it!',
render: ({ name }) => html`
<div>
...
${buttonPartial(doSomething, name)}
</div>
`,
};
The buttonPartial()
function adapts to the place where it is used - so no matter in which component definition you will use it, the passed callback for a click event can run side effects related to the component.
Helper methods
At last but not least, I would like to share with you yet another unique approach. The template engine includes helper methods for setting the unique key of the template, dynamically defining web components and passing text-based styles.
The first one - key(id)
- allows efficient re-order elements of the array. The lit-html
requires using repeat()
directive if we want to notify the library about items identifiers. Here you have an example from its documentation:
const employeeList = (employees) => html`
<ul>
${repeat(employees, (employee) => employee.id, (employee, index) => html`
<li>${index}: ${employee.familyName}, ${employee.givenName}</li>
`)}
</ul>
`;
And this is an explanation of the feature:
In most cases, using loops or
Array.map
is an efficient way to build repeating templates. However, if you want to reorder a large list, or mutate it by adding and removing individual entries, this approach can involve recreating a large number of DOM nodes.The repeat directive can help here. Directives are special functions that provide extra control over rendering. lit-html comes with some built-in directives like
repeat
.
Isn't cooler in hybrids, that if you want to hold generated templates in the DOM, all you have to do is to add .key()
at the end of the html
call? Let's try to write the above example with the hybrids template engine:
const employeeList = (employees) => html`
<ul>
${items.map(({ id, familyName, givenName }, index) =>
html`<li>${index}: ${familyName}, ${givenName}</li>`.key(id),
)}
</ul>
`
The define()
helper allows bootstrapping only the required elements and creating a tree-like dependency structure. With usage of this helper, a complex structure of elements may require only one explicit definition at the root level. In the following example, the UiHeader
will be defined once the withHeader
flag is turned on for the first time:
import UiHeader from './UiHeader';
const UiCard = {
withHeader: false,
render: ({ withHeader }) => html`
<div>
${withHeader && html`
<ui-header>...</ui-header>
`.define({ UiHeader })}
...
</div>
`,
};
If you are going to use external CSS files for your project, the style()
helper is what you need:
// `styles` should contain text content of CSS file
import styles from './MyElement.css';
const MyElement = {
render: () => html`
<div>...</div>
`.style(styles),
};
A deeper explanation of how to use template helpers can be found in the Iteration, Dependencies and Styling section of the hybrids library documentation.
Summary
Those three features of the template engine that you get for free with the hybrids library show how little differences can make a huge impact on how we write code. The lit-html was created as a general-purpose rendering library, so some of the ideas presented here don't fit. However, in hybrids, the goal is one - make the best possible experience in building web components.
What's next?
Through the last articles, we have learned the main concepts, which apply to factories provided by the library. Let's take a closer look at them to know how to use their powers. One of those used mainly behind the scenes is the property
factory (using translation feature). What happens when you define a property as a simple primitive value or complex object? We will find out with the next article from the series!
In the meantime, you can read more about the library at the project documentation.
🙏 How can you support the project? Give the GitHub repository a ⭐️, comment below ⬇️ and spread the news about hybrids to the world 📢!
Cover photo by Kelly Sikkema on Unsplash
Top comments (1)
Thanks! Hybrids engine tries to mimic native html as much as possible, so there are no special characters to bind properties or attach events. The second difference is the host argument. It follows the library pattern to pass context as a first argument of the event listeners. Nested templates also take the context from the parent so that you can make reusable offsets very easily.
You can read more in the template engine section of the docs hybrids.js.org/template-engine/ove...