DEV Community

Cover image for From classes to plain objects and pure functions
Dominik Lubański for hybrids

Posted on • Updated on

From classes to plain objects and pure functions

This is the first in a series of posts about core concepts of hybrids - a library for creating Web Components with simple and functional API.

ES2015 has introduced classes that are now widely used in UI libraries and frameworks. However, are they the best way for creating component-based logic in JavaScript? In my last post, I've highlighted some of the main classes pitfalls:

The hybrids library is the result of research on how we can take a different approach, and create simple and functional tools for building web components. However, the only way to create a custom element is to use a class, which extends HTMLElement, and define it with Custom Elements API. There is just no other way (you can also use function constructor with properly reflected super() call). So, how is it possible that hybrids uses plain objects instead of classes?

The answer is a combination of three property-related concepts used together: property descriptors, property factories, and property translation. Let's break down those concepts into the step by step process with a simple custom element definition.

Step 1: Use Custom Elements API

For a better understanding of the process, we are going to use an example with minimal requirements of the Custom Elements API. The goal here is to show how we can switch from class definition to plain object with pure functions.

class MyElement extends HTMLElement {
  constructor() {
    this.firstName = 'Dominik';
    this.lastName = 'Lubański';
  }

  get fullName() {
    return `${this.firstName} ${this.lastName}`;
  }
}

customElements.define('my-element', MyElement);
Enter fullscreen mode Exit fullscreen mode

Our custom element definition has two simple properties (firstName and lastName) and one computed property, which returns the concatenation of the first two. The example does not contain methods, but they can be easily transformed using the same process (you can define a method as computed property, which returns a function).

Step 2: Desugar class syntax using the prototype

The class syntax is nothing more than syntactical sugar on top of the function and its prototype. Every class definition has prototype property, which holds the class methods (expect constructor). What is important, we can change it after the definition, so the body of the class can be empty. Properties can be defined directly on the MyElement.prototype using Object.defineProperty() method. The prototype delegation may work unexpected with normal values, so we should define only computed properties, which return values related to the context.

class MyElement extends HTMLElement {}

// before: this.firstName in constructor()
Object.defineProperty(MyElement.prototype, 'firstName', {
  get: function get() {
    return this._firstName || 'Dominik';
  },
  set: function set(val) {
    this._firstName = val;
  },
  configurable: true,
});

// before: this.lastName in constructor()
Object.defineProperty(MyElement.prototype, 'lastName', {
  get: function get() {
    return this._lastName || 'Lubański';
  },
  set: function set(val) {
    this._lastName = val;
  },
  configurable: true,
});

// before: fullName computed property in the class body
Object.defineProperty(MyElement.prototype, 'fullName', {
  get: function fullName() {
    return `${this.firstName} ${this.lastName}`;
  },
  configurable: true,
});

customElements.define('my-element', MyElement);
Enter fullscreen mode Exit fullscreen mode

It may seem that we have taken a step back. The code has become more verbose and redundant (A simple structure of the class definition was one of the reasons for the introduction of the class syntax). Also, the current implementation is not consistent with the original one. If we set one of the properties to falsy value, it will still return a default value. We'll take care of that in the fifth step. For now, we have to focus on cleaning out our definition.

Step 3: Hide redundant code into the custom definition

All properties are defined by the Object.defineProperty() method. We can extract passed arguments to a map of property names and descriptors, and put the rest into the custom function, which will replace customElements.define() method.

const MyElement = {
  firstName: {
    get: function get() {
      return 'Dominik' || this._firstName;
    },
    set: function set(val) {
      this._firstName = val;
    },
  },
  lastName: {
    get: function get() {
      return 'ConFrontJS' || this._lastName;
    },
    set: function set(val) {
      this._lastName = val;
    },
  },
  fullName: {
    get: function fullName() {
      return `${this.firstName} ${this.lastName}`;
    },
  },
};

defineElement('my-element', MyElement);
Enter fullscreen mode Exit fullscreen mode

This is how the property descriptors concept works. The MyElement is now a plain object with a map of property descriptors, which we define on the custom element prototype.

Our defineElement() function could be defined like this:

function defineElement(tagName, descriptors) {
  class Wrapper extends HTMLElement {}

  Object.keys(descriptors).forEach(key => {
    Object.defineProperty(Wrapper.prototype, key, {
      ...descriptors[key],
      configurable: true,
    });
  });

  return customElements.define(tagName, Wrapper);
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Get rid of "this"

The custom function opens the way for further optimization. From now, we have all control over the structure of the input. Instead of passing through property descriptors to Object.defineProperty(), a function can create them dynamically. We can finally kill the last standing bastion - this keyword.

The first argument of get and set methods may become a host - an element instance. Because of that, we no longer have to access a custom element instance by this keyword. Moreover, methods are pure - they depend only on arguments and have no side effects. Removing context also allows using some of the useful features of ES2015 like arrow functions and destructuring function parameters.

const MyElement = {
  firstName: {
    get: ({ _firstName }) => _firstName || 'Dominik',
    set: (host, value) => { host._firstName = value; },
  },
  lastName: {
    get: ({ _lastName }) => _lastName || 'Lubański',
    set: (host, value) => { host._lastName = value; },
  },
  fullName: {
    get: ({ firstName, lastName }) => `${firstName} ${lastName}`,
  },
};
Enter fullscreen mode Exit fullscreen mode

Our definition has shrunk significantly. We have replaced ordinary functions with arrow functions, and the host parameter has been destructured for the get calls.

Step 5: Add middleware to save property value

A computed property by design doesn't hold its value. The definition is a pair of functions (not values), which one of them returns the current state of the property taken from external dependencies, and second updates those external dependencies. In our current solution firstName and lastName properties depend on _firstName and _lastName properties from the custom element instance (they are defined when set method is invoked for the first time).

Using the fact from the third step, we can introduce a local variable during the property definition in our custom define function. The value can be passed to get and set methods as a new last argument - lastValue. From now, get and set methods should return the current value of the property.

const MyElement = {
  firstName: {
    get: (host, lastValue = 'Dominik') => value,
    set: (host, value, lastValue) => value,
  },
  lastName: {
    get: (host, lastValue = 'Lubański') => value,
    set: (host, value, lastValue) => value,
  },
  fullName: {
    get: ({ firstName, lastName }) => `${firstName} ${lastName}`,
  },
};
Enter fullscreen mode Exit fullscreen mode

You can notice how default values are handled now. We've started using another ES2015 feature - default parameters. Those arguments are initialized with default values if no value or undefined is passed. It's much better than the solution with || operator. Although, the firstName and lastName sill return Dominik or Lubański if we set them to undefined (In a real-world scenario, it is not a problem, as we can use a built-in factory from the library, which covers that case).

Step 6: Introduce property factory

After all of the optimizations, we can find redundant code again - firstName and lastName property descriptors have become almost the same. Only a default value is different. To make it cleaner and simpler we can create a function - property factory, which returns property descriptor parameterized by the arguments.

export function property(defaultValue) {
  return {
    get: (host, lastValue = defaulValue) => value,
    set: (host, value) => value,
  };
}
Enter fullscreen mode Exit fullscreen mode

We can now replace firstName and lastName descriptors with property() function invocation:

import property from './propertyFactory';

const MyElement = {
  firstName: property('Dominik'),
  lastName: property('Lubański'),
  fullName: {
    get: ({ firstName, lastName }) => `${firstName} ${lastName}`,
  },
}
Enter fullscreen mode Exit fullscreen mode

With the property factories concept, we can define properties with only one line of code! Factories hide implementation details and minimize redundant code.

Step 7: Introduce property translation

We still have the last concept to follow. Our custom define function takes only descriptors, which are objects with pre-defined structure. What could happen if we allowed passing primitives, functions, or even objects, but without defined methods?

The property translation concept provides a set of rules for translating property definition that does not match property descriptor structure. It supports primitives, functions, or even objects (without descriptors keys).

For example, if we set the value of the firstName property to a primitive, the library uses the built-in property factory to define it on the prototype of the custom element. In another case, if you set property value as a function, it is translated to a descriptor object with get method.

In the result, custom element definition can be a simple structure of default values and pure functions without external dependencies:

const MyElement = {
  firstName: 'Dominik',
  lastName: 'Lubański',
  fullName: ({ firstName, lastName }) => `${firstName} ${lastName}`,
}
Enter fullscreen mode Exit fullscreen mode

Summary

Here is the end of today's coding journey. In the last step we have created the simplest possible definition without class and this syntax, but with truly composable structure with pure functions.

The whole process has shown that it is possible to replace imperative and stateful class definition with a simple concept of property descriptors. The other two, property factories and property translation, allow simplifying the definition either further.

What's next?

Usually, custom elements do much more than our simple example. They perform async calls, observe and react to changes in the internal and external state and many more. To cover those features, component-based libraries introduced sophisticated lifecycle methods and mechanisms for managing external and internal state. What would you say if all of that was no longer needed?

In the next post of the series, we will go deeper into the property descriptor definition and know more about the cache mechanism, change detection and independent connect method.

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 second blog post ever written - any kind of feedback is welcome ❤️.

Cover photo by Patrick Robert Doyle on Unsplash

Latest comments (11)

Collapse
 
pociej profile image
Grzegorz Pociejewski

Hmm to be honest i dont understand step 4, to get rid of "this", there must be something more that allows using current instance as first argument of setter, the glue code seems to be missing.

Collapse
 
smalluban profile image
Dominik Lubański

The change is simple. You can think of this keyword as a different type of argument of the function. Instead of using it, we can expand the arguments list by passing the context (in actual getter/setter property definition) as the first argument (shifting rest of them).

If you want to know more, I recommend to see the implementation in the library. For example here: github.com/hybridsjs/hybrids/blob/...

Collapse
 
paulen_8 profile image
🌹

Awesome, love it. Hope to see this project go far!

Collapse
 
smalluban profile image
Dominik Lubański

Thanks! I have a lot of cool ideas, so stay tuned for the updates!

Collapse
 
basickarl profile image
Karl Morrison • Edited
    fullName: ({ firstName, lastName }) => `${firstName} ${lastName}`
                 ^

TypeError: Cannot destructure property `firstName` of 'undefined' or 'null'.

Your last example.

Collapse
 
smalluban profile image
Dominik Lubański • Edited

I think you just put my example in the file and run it ;) Then of course fullName will not work - it is just a function that requires a host as an argument. The library is doing that when it defines properties - this example uses translation feature, so the final definition (if you pass this to define('my-element', MyElement)) will be:

const MyElement = {
  firstName: 'Dominik',
  lastName: 'Lubański',
  fullName: {
    get: ({ firstName, lastName }) => `${firstName} ${lastName}`,
  },
};

define('my-element', MyElement);

Then your custom element will have element.fullName property, which works and returns concatenated first and last name.

Collapse
 
basickarl profile image
Karl Morrison

I appreciate the answering, however it's still failing!

define('my-element', MyElement);
^

ReferenceError: define is not defined
    at Object.<anonymous> (/home/karl/dev/javascript/sandbox.js:10:1)
    at Module._compile (internal/modules/cjs/loader.js:959:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:995:10)
    at Module.load (internal/modules/cjs/loader.js:815:32)
    at Function.Module._load (internal/modules/cjs/loader.js:727:14)
    at Function.Module.runMain (internal/modules/cjs/loader.js:1047:10)
    at internal/main/run_main_module.js:17:11

I'm trying to run this in Node v12

From the looks of it you are using RequireJS? How would a pure JavaScript implementation look like?

Thread Thread
 
smalluban profile image
Dominik Lubański

My code is not fully working example ;) It's a most important part of the file. You still need to import the library. If you use some kind of bundler you can use this:

import { define } from 'hybrids';
...

If you want "pure" JS solution, you can use ES modules:

<script type="module">
  import { define } from 'https://unpkg.com/hybrids@4.0.3/src';

  const MyElement = {
    firstName: 'Dominik',
    lastName: 'Lubański',
    fullName: {
      get: ({ firstName, lastName }) => `${firstName} ${lastName}`,
    },
  };

  define('my-element', MyElement);
</script>

Or for older browsers support:

<script src="https://unpkg.com/hybrids@4.0.3/dist/hybrids.js"></script>
<script>
  var define = window.hybrids.define;

  ...
</script>

Read more in Getting Started section of the library documentation.

Collapse
 
clozach profile image
Chris Lozac'h

Well done, exploring a more functional approach!

There may be an issue with your use of the word "pure," both in the title of this article and in the hybrid docs. Either it's unclear how hybrids uses pure functions, or the term "pure" isn't accurate and is best left out. As it is, the term left me confused, and I'll attempt to explain why.

Per Wikipedia and others, a function must meet two criteria to be pure: idempotency (which you have) and 0 side effects.

Here's the first function used as an example in the Hybrid docs:

export function increaseCount(host) {
  host.count += 1;
}

This function isn't pure because it mutates host. Here's one way to get similar functionality from a pure function:

export function increaseCount(oldCount) {
  return oldCount + 1;
}

Or, perhaps more usefully…

function increaseCount(oldHost) {
  return Object.assign(
    { count: oldHost.count + 1 },
    oldHost
  )
}

In your place, I'd either remove the term "pure", or update the docs to explicitly demonstrate how hybrids takes advantage of functional purity.

None of this should be taken as feedback on the system itself. The approach you offer is intriguing. Bravo!

Collapse
 
smalluban profile image
Dominik Lubański • Edited

Thanks for the comment! In hybrids pure function term relates mainly to the property descriptor methods (get and set), where you don't mutate host element - it is only used to get dependencies.

Obviously, connect method is not pure - it is created especially for side effects, like adding event listeners. However, for the user of the library, the definition can be a simple object with values and pure functions - and usually, it is, when you use built-in factories or created by yourself.

The increaseCount from the example is a side effect in some way (not technically) - it is a callback attached to the button - it is not an integral part of the definition of the custom element.

Hybrids is a UI library for creating Web Components, which favors plain objects and pure functions over class and this syntax.

This is the first sentence of the docs. As you can see, it means, that library favors pure functions, not require to use them. Also, there is no statement, that all of the functions should be pure :)

Collapse
 
clozach profile image
Chris Lozac'h

Ah, that last point is subtle. Some of my confusion came from having skimmed over the code samples in the docs trying to find an example that showed pure functions in use, and didn't find any.

Not a complaint, mind you. Just sharing a perspective in case you feel it makes sense to more explicitly demonstrate the ways in which hybrids promotes the use of pure functions. The other part — about how it demotes class and this syntax — is clear from the docs as-is…and that's something I really like about what you've done!

Thanks for your thoughtful reply. :)