DEV Community

Cover image for Say goodbye to lifecycle methods, and focus on productive code
Dominik Lubański for hybrids

Posted on • Updated on

Say goodbye to lifecycle methods, and focus on productive code

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...
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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 }));
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

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>
  `,
};
Enter fullscreen mode Exit fullscreen mode

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) => {...},
};
Enter fullscreen mode Exit fullscreen mode

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),
  };
};
Enter fullscreen mode Exit fullscreen mode

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]),
};
Enter fullscreen mode Exit fullscreen mode

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);
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

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.

GitHub logo hybridsjs / hybrids

The simplest way to create web components from plain objects and pure functions! 💯

hybrids - the web components

npm version bundle size types build status coverage status npm gitter twitter Conventional Commits code style: prettier GitHub

🏅One of the four nominated projects to the "Breakthrough of the year" category of Open Source Award in 2019

hybrids is a UI library for creating web components with unique declarative and functional approach based on plain objects and pure functions.

  • The simplest definition — just plain objects and pure functions - no class and this syntax
  • No global lifecycle — independent properties with own simplified lifecycle methods
  • Composition over inheritance — easy re-use, merge or split property descriptors
  • Super fast recalculation — smart cache and change detection mechanisms
  • Global state management - model definitions with support for external storages
  • Templates without external tooling — template engine based on tagged template literals
  • Developer tools included — HMR support out of the box for a fast and pleasant development

Quick Look

Add the hybrids npm package to your application, import required features, and define your custom element:

import { html
Enter fullscreen mode Exit fullscreen mode

🙏 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)