State

A state object that can reconcile changes from multiple sources

Overview

Elix components are written in functional-reactive style using ReactiveMixin. A core concept in that model is that a component should have a read-only state member that embodies all of the component's current state information.

This state pattern is used in many UI frameworks, including React, Vue, and others. In those frameworks, the state object is typically represented by a plain JavaScript object, and updated via a setState method.

Elix generally follows this model, but the extensive factoring of component UI logic into mixins has implications for the representation of state. For example, it is common for one mixin to manage a state member a that has some relationship with a state member b which is managed by another mixin. When the second mixin calls setState to update b, the first mixin may need to reflect that update by updating a. This needs to be done in such a way that the two mixins are otherwise independent of each other.

To handle these scenarios, Elix represents a component's state as an instance of the State class. Instances of State know how to participate in the construction of a new state that can satisfy the relationships between state members.

Responding to state changes with onChange

To offer a concrete example, SingleSelectionMixin manages a state member called selectedIndex, and the independentContentItemsMixin manages a separate but related state member called items. We would like to guarantee that the selectedIndex value should always be a valid array index into the items array (or the special value -1, which indicates no selection).

To maintain this invariant, if ContentItemsMixin updates the items array, SingleSelectionMixin needs to check that selectedIndex is still a valid array index. If not, it will want to adjust selectedIndex to fall within the new bounds of the items array. Exactly how it will do that depends on several factors, but a simplistic answer is that, if selectedIndex is now too large, it will be clipped to the new size of the array.

SingleSelectionMixin accomplishes this by registering a change handler that will run whenever items changes via setState. When SingleSelectionMixin is asked for the component's defaultState, it invokes onChange on the state being constructed to register a change handler. A much-simplified version of the code looks like this:

const SingleSelectionMixin = (Base) => class SingleSelection extends Base {
  get defaultState() {
    const state = super.defaultState;

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

    return state;
  }
}

The change handler above will run whenever code invokes setState to make a change to the items property. The handler inspects the state's current selectedIndex and the length of the items array, then forces the index to fit with the array's bounds. The handler returns a new object containing the final selectedIndex value. That value may or may not be different than the current selectedIndex; if it is different, the updated selectedIndex is applied to the state, and any change handlers defined elsewhere that trigger on selectedIndex will run.

Naturally, it's possible to create situations in which multiple state change handlers update values in such a way as to trigger an infinite loop. However, the Elix project has found that, at the scale of Elix's web components, such situations can easily be avoided with proper care.

API

copyWithChanges(changes) method

Return a new copy of this state that includes the indicated changes, invoking any registered onChange handlers that depend on the changed state members.

There is no need to invoke this method yourself. ReactiveMixin will take care of doing that when you invoke setState.

Parameters:

  • changes: objectthe changes to apply to the state

Returns: object - the new `state`, and a `changed` flag indicating whether there were any substantive changes

onChange(dependencies, callback) method

Ask the State object to invoke the specified callback when any of the state members listed in the dependencies array change.

The callback should be a function that accepts:

  • A state parameter indicating the current state.
  • A changed parameter. This will be a set of flags that indicate which specified state members have changed since the last time the callback was run. If the handler doesn't care about which specific members have changed, this parameter can be omitted.

The callback should return null if it finds the current state acceptable. If the callback wants to make changes to the state, it returns an object representing the changes that should be applied to the state. The callback does not need to check to see whether the changes actually need to be applied to the state; the State object itself will avoid applying unnecessary changes.

The common place to invoke onChange is when an element's defaultState is being constructed.

Parameters:

  • dependencies: Array.|stringthe name(s) of the state members that should trigger the callback if they are changed
  • callback: functionthe function to run when any of the dependencies changes