This is the second in a series of posts about core concepts of hybrids - a library for creating Web Components with simple and functional API.
One of the most rooted features of component-based UI libraries is a complex lifecycle. It is a group of methods, which provide full control over the state of the component that may change over time. Usually, libraries use self-explaining name convention and call did* methods after something happens and will* before the change. While studying the library docs, we often find a whole range of possibilities, which can lead to confusion or even frustration. After all, you need to have an in-depth understanding to create correct and efficient code. For example, the component state may depend on a specific sequence of events in time, which makes the code hard to test and eventually maintain or extend.
Is it so bad?
Let's face it two obscure facts about lifecycle methods. Firstly, they shift the burden of state management from the library to us. As it might look legit, it usually means, that we have to write more redundant code manually:
class MyComponent extends Component {
componentDidUpdate(prevProps) {
if (this.props.name !== prevProps.name) {
// do something...
}
}
}
In the above example, the library provides a map of previous properties, but it doesn't inform which of them has a new value. We have to create conditions explicitly to be sure that our code is called only if the name
property has changed.
In another hand, if a component requires asynchronous data, lifecycle structure may force to fetch data twice - for the first time in something like componentDidMount()
method, and then each time in componentDidUpdate()
when the dependencies change:
import { getUser } from './api';
class MyComponent extends Component {
componentDidMount() {
this.fetch();
}
componentDidUpdate(prevProps) {
if (this.props.userId !== prevProps.userId) {
this.fetch();
}
}
fetch() {
getUser(this.props.userId)
.then((data) => this.setState({ data }));
}
}
Even though we have extracted redundant logic into the fetch()
method, it has to be called twice in two separate lifecycle methods.
Both code examples might look familiar to you. In fact, they represent what the React.Component
class provides. React of course is not a web components library, but LitElement, Omi, Slim.js, Stencil and many others follow the trends, and they implemented very similar concepts (use the links to go to the lifecycle section of libraries documentation).
In the first post of the series, we have learned how we can switch component definition from the class syntax into the map of independent property descriptors. If you haven't read it yet, it's a good moment to do so:
From classes to plain objects and pure functions
Dominik Lubański for hybrids ・ Jan 10 '19
This time we will go deeper into the property descriptor definition and learn more about cache mechanism, change detection and its connect
method.
Different approach
Lifecycle methods pushed us to think more about when something happens rather than to define how we can get what we need. What would you say if you could focus on value computations and leave the rest to the library?
The hybrids property descriptors concept introduced much more than only a middleware for holding property value. The library provides a complete cache and change detection mechanism.
A component, which requires data fetched asynchronously can be defined with hybrids just like that:
import { html } from 'hybrids';
import { getUser } from './api';
const AsyncUser = {
userId: 1,
data: ({ userId }) => getUser(userId),
render: ({ data }) => html`
<div>
${html.resolve(
data.then(user => html`
<span>${user.firstName}</span>
`),
)}
</div>
`,
};
Click here to play with a live example on ⚡️StackBlitz
The above definition includes userId
, data
and render
descriptors. The data
property depends on userId
and returns a promise with user details. Don't bother much about the render
property for now. You should need to know now that it uses under the hood the render
factory (using property translation), which uses html
function to create and update contents of the custom element. In the body of the template, we are using dynamic value, which resolves data
promise to an element with the first name of the user.
Cache mechanism
The cache mechanism is attached to the getter and setter of every property defined by the library. For set
method it automatically updates the cache if calculation returns a new value. For get
method cache ensures that the value is only computed if needed, for example, when one of the property dependency has changed. In our example, it means, that getUser()
will be called to set an initial value and only when userId
will change. How does it work?
The cache controls the data
, as well as userId
property. When userId
is called inside of the data
getter, the cache can save it as a data
dependency. Next time, when we call data
, cache checks userId
from the cache and calls getUser(userId)
only if userId
has changed. Otherwise, it returns the last cached value and omits getter. The cache is global for all elements defined by the library so we can depend on properties defined in other elements too!
The cache concept uses the fact that properties are never computed if they are not called (even if the dependencies have changed). You could try to get a value of data
manually, and you would see, that it returns the same promise all the time. However, if you change userId
property, data
will return a new promise called next time.
Simplified lifecycle
In the first post, we have learned that the property descriptor may have get
and set
methods. Actually, you can define two more for property lifecycle control - connect
and observe
method. connect
method can return a function, which is called when an element is disconnected. While the observe
method is called asynchronously when the property value changes.
{
get: (host, lastValue) => {...},
set: (host, value, lastValue) => {...},
connect: (host, key, invalidate) => {
// ...
return () => {...}; // disconnect
},
observe: (host, value, lastValue) => {...},
};
However, in the above AsyncUser
example we didn't have to use it explicitly. We even didn't have to create property descriptors at all! If we would take all the concepts together, we may start to see a bigger picture here. The raw descriptor provides all the required features to create stateful properties. Then the library adds on top of that cache mechanism. However, the preferred way to define properties is to use built-in or custom factories (functions, that produce descriptors). As the property definition is independent, you can re-use factories wherever you want. As the result, you don't have to define connect
method by yourself, and you can focus on productive coding in a declarative way!
Invalidation
You may have noticed a third argument of the connect
method - invalidate
callback. If a property has only a getter, but it depends on third-party tools, invalidate
is a clever way to notify cache, that value should be computed next time. Because of the functional structure, it is super easy to create properties connected to external state managers like redux:
import store from './store';
function connect(store, mapState) {
return {
get: (host) => mapState(store.getState(), host),
connect: (host, key, invalidate) => store.subscribe(invalidate),
};
};
Redux subscribe
method takes a callback where we can pass invalidate
. It returns unsubscribe function so we can call it in the connect method defined as an arrow function. We can use the factory in the component definition, like in the following example:
import store from './store';
import connect from './connectFactory';
const MyElement = {
userId: 1,
userDetails: connect(store, ({ users }, { userId }) => users[userId]),
};
Change detection mechanism
In the last part of the post let's go back to render
property. If the library does not call getters for us, how is it possible that our component works? Even though render
might look special, is it the same property descriptor as the rest. The difference is in how the render
factory uses connect
and observe
methods.
The best way to understand how render
works is to built a simplified version:
function render(fn) {
return {
get: (host) => fn(host),
connect: (host, key) => {
if (!host.shadowRoot) host.attachShadow({ mode: 'open' });
},
observe: (host, fn) {
fn(host, host.shadowRoot);
},
};
}
Our render
factory returns descriptor with get
, connect
and observe
methods. We took advantage of the cache mechanism, so our getter calls fn
and saves its dependencies. The property value will be only recalculated if one of the properties used in the fn
changes.
The connect
creates shadowRoot
if it is not already there. Then we want to call fn
whenever dependencies change. It is exactly what observe
method provides. It might looks familiar to componentDidUpdate()
callbacks from other libraries. Eventually, we want to do something when the change occurs. However, the idea behind the observe
method is much deeper. The library calls it only when the value of the property has changed. This method is also called only once during the current event loop, because of the internal queue scheduled with requestAnimationFrame
API. We don't have to bother to check what property has a new value or not because we covered it with the cache mechanism.
Summary
It might be a lot of new stuff to process. For sure, hybrids didn't give up on lifecycle methods. They are just redesigned and implemented in the opposite direction to patterns known from other libraries. In the explained component example, the chain of cause and effect goes from render property to data (in other libraries it would go from fetching data to rendering new state). A function, which creates a template, wants user details, and only because of that they are fetched, and they eventually trigger an update of the template. If in some condition the template would not require those data, they would not be fetched at all.
We can call it simplified lifecycle. If we add on top of that smart cache mechanism and all already known property-based concepts, it changes everything. We can shift the most of state-related responsibility to the library and focus on the business logic of our components. Usually, the component requires a list of properties for holding simple or computed values and render method for creating element structure. If we need something not covered by the library, we can easily create reusable factories and still do not use lifecycle methods directly.
What's next?
Today, we have scratched the surface of the render
factory. In the next post of the series, we will learn more about render factory provided by the library, as well as the rich template engine built on top of tagged template literals.
In the meantime, you can read more about the hybrids library at the project documentation.
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 features for building modern web applications:
- 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
- Layout Engine making UI layouts development much faster
- Localization with automatic translation of the templates content
- Hot Module Replacement support without any additional configuration
Documentation
The project documentation is available at the hybrids.js.org site.
Quick Look
Component Model
It's based on plain objects and pure functions1, still using the Web Components API under the hood:
import { html, define } from "hybrids";
function increaseCount(host) {
host.count += 1;
}
export default define({
tag:
…🙏 How can you support the project? Give the GitHub repository a ⭐️, comment below ⬇️ and spread the news about hybrids to the world 📢!
👋 Welcome dev.to community! My name is Dominik, and this is my third blog post ever written - any kind of feedback is welcome ❤️.
Cover photo by Paul Skorupskas on Unsplash
Top comments (0)