Perhaps you like the benefits of functional reactive programming, but would like to create native web components with minimal overhead. This post explores a relatively simple JavaScript mixin that lets you author web components in a functional reactive programming (FRP) style modeled after React. This mixin focuses exclusively on managing state and determining when the state should be rendered. You can use this mixin with whatever DOM rendering technology you like: virtual-dom, hyperHTML, lit-html, plain old DOM API calls, etc.
I spent several months this summer writing React and Preact components, and the values of an FRP model were immediately clear. As React advocates claim, using FRP does indeed make state easier to reason about and debug, code cleaner, and tests easier to write.
I wanted to bring these reactive benefits to the Elix web components project which Component Kitchen leads. Elix already uses functional mixins extensively for all aspects of component functionality for everything from accessibility to touch gestures. I wanted to see if it were possible to isolate the core of an FRP architecture into a functional mixin that could be applied directly to HTMLElement to create a reactive web component.
You can use the resulting ReactiveMixin to create native web components in a functional reactive style. FRP frameworks often use a canonical increment/decrement component as an example. The ReactiveMixin version looks like this:
import ReactiveMixin from '.../ReactiveMixin.js'; // Create a native web component with reactive behavior. class IncrementDecrement extends ReactiveMixin(HTMLElement) { // This property becomes the initial value of this.state at constructor time. get defaultState() { return { value: 0 }; } // Provide a public property that gets/sets state. get value() { return this.state.value; } set value(value) { this.setState({ value }); } // Expose "value" as an attribute. attributeChangedCallback(attributeName, oldValue, newValue) { if (attributeName === 'value') { this.value = parseInt(newValue); } } static get observedAttributes() { return ['value']; } // … Plus rendering code, with several options for rendering engine } customElements.define('increment-decrement', IncrementDecrement);
You end up with something that’s very similar to React’s Component class (or, more specifically, PureComponent), but is a native HTML web component. The compact mixin provides a small core of features that enable reactive web component development in a flexible way.
ReactiveMixin gives the component a member called this.state
, a
dictionary object with all state defined by the component and any of its other
mixins. The state
member, which is read-only and immutable, can
be referenced during rendering, and to provide backing for public properties
like the value
getter above.
ReactiveMixin provides a setState
method the component invokes to
update its own state. The mixin sets the initial state in the constructor by
passing the value of the defaultState
property to
setState
.
When you call setState
, ReactiveMixin updates the component’s
state, and then invokes a shouldComponentUpdate
method to
determine whether the component should be rerendered.
The default implementation of shouldComponentUpdate
method
performs a shallow check on the state properties: if any top-level state
properties have changed identity or value, the component is considered dirty,
prompting a rerender. This is comparable to the similar behavior in
React.PureComponent
. In our explorations, we have found that our
web components tend to have shallow state, so pure components are a natural
fit. You can override this to provide a looser dirty check (like
React.Component
) or a tighter one (to optimize performance, or
handle components with deep state objects).
If there are changes and the component is in the DOM, the new state will be rendered.
This mixin stays intentionally independent of the way you want to render state to the DOM. Instead, the mixin invokes an internal component method whenever your component should render, and that method can invoke whatever DOM updating technique you like. This could be a virtual DOM engine, or you could just do it with plain DOM API calls.
Here’s a plain DOM API render implementation for the increment/decrement example above. We’ll start with a template:
<template id="template"> <button id="decrement">-</button> <span id="value"></span> <button id="increment">+</button> </template>
To the component code above, we’ll add an internal render method for
ReactiveMixin to invoke. The mixin uses a Symbol
object to
identify the internal render method. This avoids name collisions, and
discourages someone from trying to invoke the render method from the outside.
(The render method can become a private method when JavaScript supports
those.)
import symbols from ‘.../symbols.js’; // This goes in the IncrementDecrement class ... [symbols.render]() { if (!this.shadowRoot) { // On our first render, clone the template into a shadow root. const root = this.attachShadow({ mode: 'open' }); const clone = document.importNode(template.content, true); root.appendChild(clone); // Wire up event handlers too. root.querySelector('#decrement').addEventListener('click', () => { this.value--; }); root.querySelector('#increment').addEventListener('click', () => { this.value++; }); } // Render the state into the shadow. this.shadowRoot.querySelector('#value').textContent = this.state.value; }
That’s all that’s necessary. The last line is the core bit that will update
the DOM every time the state changes. The two buttons update state by setting
the value
property, which in turn calls
setState
.
This ReactiveMixin would also be a natural fit with template literal libraries like lit-html or hyperHTML. That could look like:
import { html, render } from ‘.../lit-html.js’; import symbols from ‘.../symbols.js’; // Render using an HTML template literal. [symbols.render]() { if (!this.shadowRoot) { this.attachShadow({ mode: 'open' }); } const template = html` <button on-click=${() => this.value-- }>-</button> <span>${this.state.value}</span> <button on-click=${() => this.value++ }>+</button> `; render(template, this.shadowRoot); }
The creation of the shadow root and the invocation of the rendering engine are boilerplate you could factor into a separate mixin to complement ReactiveMixin.
Since components created with this mixin are still regular web components,
they receive all the standard lifecycle methods. ReactiveMixin augments
connectedCallback
so that a component will be rendered when it’s
first added to the DOM.
The mixin provides React-style lifecycle methods for
componentDidMount
(invoked when the component has finished rendering for the first time) and
componentDidUpdate
(whenever the component has completed a
subsequent rerender). The mixin doesn’t provide
componentWillUnmount
; use the standard
disconnectedCallback
instead. Similarly, use the standard
attributeChangedCallback
instead of
componentWillReceiveProps
.
This ReactiveMixin gives us much of what we like about React, but lets us write web components which are closer to the metal. All it does is help us manage a component’s state, then tell our component when it needs to render that state to the DOM. Separating state management from rendering is useful — we’ve already changed our minds about which rendering engine to use several times, but those changes entailed only minimal updates to our components.
The coding experience feels similar to React’s, although I don’t see a need to make the experience identical. For example, I thought setState would work well as an `async` Promise-returning function so that you can wait for a new state to be applied. And it’s nice to avoid all the platform-obscuring abstractions (e.g., synthetic events) React pushes on you.
We’re using this ReactiveMixin to rewrite the Elix components in a functional reactive style. That work is proceeding fairly smoothly, and we’re moving towards an initial 1.0 release of Elix that uses this approach in the near future. In the meantime, if you’d like to play with using this mixin to create web components, give it a try and let us know how it goes.