https://lorenzofox.dev/posts/component-as-infinite-loop/ lorenzofox's blog logoHome Coroutines and web components First publication date: Monday, 4 March 2024 About In the previous article we learned what coroutines are and saw some patterns they can help implement. In this article, we will see how coroutines can be used to model web components in a different way, and why you might like it. Rendering loop Among other things, coroutines have a few properties that we will use in this short essay: * They are primarily functions and can benefit from the whole functional arsenal of Javascript (composition, higher order function, delegation, etc.). * They are stateful. * You can inject pretty much any kind of data when they are paused. For example, an infinite loop within the body of the routine can be considered as a public API function. * You cannot, by design, call the next function concurrently. Introduction example Consider the following generator: function* someComponent({$host}) { while (true) { const {content = ''} = yield; $host.textContent = content; } } It takes a $host DOM element and has a rendering loop. You can wrap this generator with a function that produces a render function: const createComponent = (generator) => ({$host}) => { const gen = generator({$host}); gen.next(); // we initiate the component by entering inside the rendering loop return (state = {}) => gen.next(state); }; const HelloWorldComponent = createComponent(function* ({$host}) { while (true) { const {name = ''} = yield; $host.textContent = `hello ${name}`; } }); // div is some DOM element const render = HelloWorldComponent({ $host: div }); render({name: 'Laurent'}); render({name: 'Bernadette'}); The power of functions For now, the rendering loop is a piece of imperative code, but it can use any rendering library you want (react and so on). The first point above says that functions (and therefore coroutines) are very versatile in Javascript. We could easily go back to a known paradigm if we wanted to. For example, we use lit-html to have a declarative view instead of a bunch of imperative code: import {render, html} from 'lit-element'; const HelloWorldComponent = createComponent(function* ({$host}) { while (true) { const {name=''} = yield const template = html`
hello ${name}
`; render($host, template); } }); you can draw the template into a function: import {html} from 'lit-element'; const template = ({name = ''} = {}) => html`hello ${name}
`; And compose with a new higher order function: import {render} from 'lit-element'; const withView = (templateFn) => function* ({$host}) { while (true) { render($host, templateFn(yield)); } }; const HelloWorldComponent = createComponent(withView(template)); All right, we are on familiar ground: our component is now a simple function of the state ({name}) => html`hello ${name}
`; Maintaining a state Having an infinite rendering loop to model our component can actually be more interesting than it seems at first: you can have a state in the closure of that loop. If we first modify the higher-level createComponent function a little to bind the render function to the host element: const createComponent = (generator) => ({$host}) => { const gen = generator({$host}); gen.next(); $host.render = (state = {}) => gen.next(state); return $host; }; We can now make the component trigger its own rendering: const CountClick = createComponent(function *({$host}){ let clickCount = 0; $host.addEventListener('click', () => { clickCount+=1; $host.render(); }); while(true) { $host.textContent = `I have been clicked ${clickCount} time(s)` yield; } }); In frameworks like React, where you only have access to the equivalent of what is inside the loop, you rely on the framework extension points (the hooks in the case of React) to build this sort of mechanism, and have very little control over rendering scheduling. More HOF function to reduce the coupling. The component embeds its view and some logic at the same time. Again, we can easily decouple them so that we can reuse either the view or the logic: All we need to do is take advantage of the third property of coroutines mentioned in the introduction, and a simple delegation mechanism inherent to generators: yield*. const countClickable = (view) => function *({$host}) { let clickCount = 0; $host.addEventListener('click', () => { clickCount+=1; $host.render({count: clickCount}); }); yield* view({$host}); } This type of mixin is responsible for holding the state and triggering the rendering of any view. Rendering is left to the view thanks to delegation, while the state is passed whenever the view coroutine is paused and requires a new render: const CountClick = createComponent(countClickable(function* ({$host}) { while (true) { const {count = 0} = yield; $host.textContent = `I have been clicked ${count} time(s)`; } })); Neat ! You can now use the "clickable" behaviour independently, on different views. In the same way, you can plug the view into a different controller logic, as long as it passes the expected data interface ({ count: number | string}): note how the data comes from the yield assignation. We will see more patterns like this in future articles. Web components and lifecycle mapping So far we have designed our component to be a function of the host. We can go further and ensure that the rendering routine is actually private to the host, so that the rendering code is encapsulated inside along with any potential behaviour enhancements (the countClickable mixin for example), while both remain reusable. Let's look at another way of modelling custom elements. To enhance your HTML document, you can teach the browser new ones using its registry and the define method. customElements.define('hello-world', class extends HTMLElement { connectedCallback() { this.textContent = `hello ${this.getAttribute('name')}` } }) // (define takes a third optional argument we won't consider for the moment) And then use the hello-world tag in the markup like any other regular HTML tag.