A simple state-based recalc engine for web components

By Jan Miksovsky on May 28, 2019

We recently released Elix 6.0. This includes a simple state-based recalc engine that lets our components know what they should update when their internal state changes.

We were inspired by Rich Harris' Rethinking Reactivity talk on version 3 of the Svelte framework, which advances the idea of building user interface components upon a spreadsheet-like recalc engine. Significantly, the recalc engine supports forward references — when one piece of data changes, the engine can efficiently determine what else must be recalculated. Svelte entails a complete toolchain that we're not ready to adopt, but we like the idea of recalc as a useful service for web components.

As it turns out, Elix already had much of what we need to build a recalc engine, and it was relatively straightforward to expand that to form a new core for our Elix components. We worked this into a core Elix mixin called ReactiveMixin, which can now let a component know exactly what state has actually changed since the last render. This in turn lets the component efficiently decide what it needs to update in the DOM.

The smallest amount of framework we can get away with

As we've noted before, it's not practical to write a production component library without any shared code. Writing web components requires enough boilerplate that most people end up using a framework, even if it's just a tiny framework they wrote themselves.

Elix has had to develop its own core library so that we can create reliable, polished, general-purpose web components. Our framework happens to be composed of JavaScript mixins. We don't particularly care to push this framework on other people, but we do discuss it from time to time in case the work we've done can help write their own framework-level code better.

We only ask a few things of our framework:

The second and third things are boring but necessary; the first part is the only interesting bit. For convenience, all three of these mixins are bundled together in a base class, ReactiveElement. But each piece is usable separately.

Example

A simple increment/decrement web component in Elix 6.0 looks like this:

import { ReactiveElement, symbols, template } from "elix";

class IncrementDecrement extends ReactiveElement {

  componentDidMount() {
    super.componentDidMount();
    this.$.decrement.addEventListener('click', () => {
      this.value--;
    });
    this.$.increment.addEventListener('click', () => {
      this.value++;
    });
  }

  // This property becomes the value of this.state at constructor time.
  get defaultState() {
    return Object.assign(super.defaultState, {
      value: 0
    });
  }

  // Render the current state to the DOM.
  [symbols.render](changed) {
    super[symbols.render](changed);
    if (changed.value) {
      this.$.valueSpan.textContent = this.state.value;
    }
  }

  // Define the initial contents of the component's Shadow DOM subtree.
  get [symbols.template]() {
    return template.html`
      <button id="decrement">-</button>
      <span id="valueSpan"></span>
      <button id="increment">+</button>
    `;
  }

  // Provide a public property that gets/sets the value state.
  // If an HTML author sets a "value" attribute, it will invoke this setter.
  get value() {
    return this.state.value;
  }
  set value(value) {
    this.setState({ value });
  }

}

Live demo

The interesting new bit in Elix 6.0 shows up in the method identified by symbols.render. That method is invoked when the component's state changes. (Aside: We identify internal methods with Symbol instances to avoid name collisions with other component code.)

The render method now gets a parameter, changed, that has Boolean values indicating which state members have changed since the last render. If changed.value is true, then this.state.value contains a new value, so the render method knows it should display the new value in the DOM as the span's textContent.

Computed state

In simple cases, a computed property can be recalculated each time it's requested. But a number of Elix components have computed state that is expensive to recalculate. In those cases, we can define a rule in our recalc engine that indicates how to recalculate a given state member when other state members change.

A toy example might look like:

class TestElement extends ReactiveMixin(HTMLElement) {

  get defaultState() {
    const result = Object.assign(super.defaultState, {
      a: 0
    });

    // When state.a changes, set state.b to be equal to state.a + 1
    result.onChange('a', state => ({
      b: state.a + 1
    }));

    return result;
  }

}

The onChange handler is associated with the component's state object, and runs whenever state.a changes. That handler returns an object containing any computed updates that should be applied to the state. Here it returns an object with a new value for state.b.

A more realistic example comes up in SingleSelectionMixin, which maintains a selectedIndex state member used to track which item in a list of items is currently selected. If the items array changes, we want to ensure that the selectedIndex state still falls with the bounds of that array.

function SingleSelectionMixin(Base) {
  return class SingleSelection extends Base {

    get defaultState() {
      const state = Object.assign(super.defaultState, {
        selectedIndex: -1
      });

      // Ask to be notified when state.items changes.
      result.onChange('items', state => {
        // Force selectedIndex state within the bounds of -1 (no selection)
        // to the length of items - 1.
        const { items, selectedIndex } = state;
        const length = items.length;
        const boundedIndex = Math.max(Math.min(selectedIndex, length-1), -1);
        return {
          selectedIndex: boundedIndex
        };
      });

      return result;
    }

  };
}

Defining a rule like this to keep an index within bounds is an important ingredient in allowing us to factor our complex components into constituent mixins. It lets one mixin or class update an aspect of state without having to know about all the secondary effects that will have.

You can see this recalculation of state in action if you open a demo like the one for Carousel and invoke the debug console. If you use the debugger to remove one of the carousel's images from the DOM, the Carousel will recalculate which item should now be selected. If the last image is selected in the carousel and you remove that image, the above code will ensure that the new last image becomes the selected one.

This isn't just an abstract experiment. This kind of resiliency is called for in the Gold Standard Checklist for Web Components criteria for Content Changes. Such resiliency is exactly the kind of quality that custom elements will need to deliver to be as reliable and flexible as the native HTML elements. The simple recalc engine in our Elix 6.0 core makes it easier for us to deliver that level of quality.

Tweet

« Blog home