Overview
Purpose: Give a component class a functional-reactive programming (FRP) architecture that can track internal state and render that state to the DOM.
This mixin forms a core part of the Elix render pipeline, managing a component's state
and rendering the component when state changes.
events → methods → setState → render DOM → post-render
Expects the component to provide:
internal.render
method that actually updates the DOM. You can use ShadowTemplateMixin to help populate the component's initial Shadow DOM tree. Beyond that, you will need to write ainternal.render
method to update elements in response to state changes; see Rendering below.internal.rendered
method that runs after the component renders.internal.firstRender
property that isundefined
before the first render,true
during the firstrender
andrendered
calls, andfalse
in subsequent renders.
Provides the component with:
internal.state
property representing the current state.internal.setState()
method to change state.
ReactiveMixin
represents a minimal implementation of the functional-reactive programming architecture populate in React and similar frameworks. The mixin itself 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: hyperHTML, lit-html, virtual-dom, etc., or plain old DOM API calls like Elix does (see below).
The Elix project itself uses ReactiveMixin
as a core part of all its components, so the mixin is included in the ReactiveElement base class. Elix components generally use the ReactiveElement
base class instead of using ReactiveMixin
directly.
Example
Functional-reactive frameworks often use a canonical increment/decrement component as an example. The ReactiveMixin version looks like this:
import * as internal from "elix/src/internal.js";
import ReactiveMixin from "elix/src/core/ReactiveMixin.js";
// Create a native web component with reactive behavior.
class IncrementDecrement extends ReactiveMixin(HTMLElement) {
// This property becomes the initial value of this[internal.state] at constructor time.
get [internal.defaultState]() {
return { value: 0 };
}
// Provide a public property that gets/sets state.
get value() {
return this[internal.state].value;
}
set value(value) {
this[internal.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);
ReactiveMixin provides a foundation very similar to React’s Component
class (or, more specifically, PureComponent
), but for native HTML web components. The compact mixin provides a small core of features that enable reactive web component development in a flexible way.
Defining state
ReactiveMixin gives the component a property called state
, a dictionary object with all state defined by the component and any of its other mixins. The state
property itself is read-only and immutable. You can reference it 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
. You can invoke setState
in response to user interaction. (How you wire up event handlers is up to you; the Rendering section below explores some ways to handle events.)
Ensuring state consistency
While state members should generally be independent of each other, sometimes two or more state members have some interrelationship. If such state members are managed by multiple mixins, it is necessary to provide some means for the mixins to verify that a new state is consistent with their expectations.
For example, SingleSelectAPIMixin manages a state member called selectedIndex, and the independent ContentItemsMixin 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, SingleSelectAPIMixin
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.
SingleSelectAPIMixin
accomplishes this by defining a function called stateEffects. When setState
is called to change state, ReactiveMixin
will invoke stateEffects
to see whether any mixin or class believes those state changes should cause any second-order effects. A much-simplified version of the code looks like this:
function SingleSelectAPIMixin(Base) {
return class SingleSelection extends Base {
get [internal.stateEffects](state, changed) {
// See if base classes define second-order effects of their own.
const effects = super[internal.stateEffects]
? super[internal.stateEffects](state, changed)
: {};
if (changed.items) {
// Ensure index within bounds of -1 .. length-1.
const { items, selectedIndex } = state;
const length = items.length;
const boundedIndex = Math.max(Math.min(selectedIndex, length - 1), -1);
// New index is a second-order effect of the items change.
Object.assign(effects, {
selectedIndex: boundedIndex,
});
}
return effects;
}
};
}
When ContentItemsMixin
invokes setState
to make a change to the items
property, ReactiveMixin
will invoke the element's stateEffects
method, including the method implementation shown here. The changed
parameter will include the flag changed.items
with a value of true to let the above code know that state.items
have changed. In that case, the above code will then calculate a new selectedIndex
that falls within the new bounds of the new items
array.
The method here returns that new selectedIndex
as a second-order effect of the change in state. The new selectedIndex
value may or may not actually be different than the current selectedIndex
. If it is different, then ReactiveMixin
will apply the effects to the state and invoke stateEffects
a second time.
On this second invocation of stateEffects
, changed.selectedIndex
will be true, but changed.items
will no longer be true. Hence, the code above will make no further modifications to state.
In this way, stateEffects
are computed and applied repeatedly. Eventually stateEffects
will return an empty object, indicating that all mixins and classes believe the state is now consistent. The new, consistent state becomes the element's new official state.
Naturally, it's possible to create situations in which multiple mixins or classes repeated affect state 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. State changes typically converge quickly to a stable state.
Rendering
When you call internal.setState
, ReactiveMixin updates your component’s state. It also checks to see whether any state members actually changed, using a shallow comparison by value for this purpose. If state members actually changed and the component is in the DOM, the component is considered dirty, and will prompt a render call.
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 an identifier from the internal module to identify the internal render method. This avoids name collisions, and discourages someone from trying to invoke the render method from the outside.
import { ReactiveMixin, internal } from "elix";
class IncrementDecrement extends ReactiveMixin(HTMLElement) {
// The following would be added to the earlier component definition...
[internal.render](changed) {
super[internal.render](changed);
// On our first render, clone the template into a shadow root.
if (this[internal.firstRender]) {
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++;
});
}
// When the value changes, show the value as the span's text.
if (changed.value) {
const value = this[internal.state].value;
this.shadowRoot.querySelector("#value").textContent = value;
}
}
}
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.
The above example can be simplified by taking advantage of HTML templates. One way to do that is with ShadowTemplateMixin, which is included in the ReactiveElement base class. See that class for an example.
Lifecycle methods
Since components created with this mixin are still regular web components, they receive all the standard web component lifecycle methods. ReactiveMixin augments connectedCallback
so that a component will be rendered when it’s first added to the DOM.
When you invoke setState
, ReactiveMixin
will:
- Asynchronously invoke your component's
internal.render
method, passing in a set ofchanged
flags indicating which state fields have actually changed. During therender
call, you must avoid callingsetState
. - Invoke your component's
internal.rendered
method when the aboverender
call has completed, again passing in a set ofchanged
flags. This is an opportune place for you to perform work that requires the component to be fully rendered, such as setting focus on a shadow element or inspecting the computed style of an element. If such work should result in a change in component state, you can safely callsetState
during therendered
method.
The first time your component is rendered, your component's internal.firstRender
property will be true
. You can use that flag in internal.render
or internal.rendered
to perform work that should only be done once, such as wiring up event listeners to elements in your component's shadow tree.
Note: Elix 11.0 and earlier defined internal.componentDidMount
and internal.componentDidUpdate
methods which have been deprecated. Use the internal.rendered
method instead, and inspect the value of internal.firstRender
. If that property is true
, you can perform the work you previously did in internal.componentDidMount
; if false, you can perform the work you previously did in internal.componentDidUpdate
. If you are wiring up event handlers, you can likely perform that work earlier in the lifecycle during internal.render
, rather than waiting for internal.rendered
.
Debugging
ReactiveMixin doesn't publicly expose an element's state
so that you can control which properties you want to make available in your component's public API. However, it can be convenient to be able to inspect a component's state while debugging.
For that reason, ReactiveMixin offers a URL parameter for debugging: if you add
#elixdebug=true
to your URL, the state
property defined by ReactiveMixin will be temporarily made public so that you can inspect in the debug console.
API
Used by classes AlertDialog, AutoCompleteComboBox, AutoCompleteInput, AutoSizeTextarea, Backdrop, Button, CalendarDay, CalendarDayButton, CalendarDayNamesHeader, CalendarDays, CalendarMonth, CalendarMonthNavigator, CalendarMonthYearHeader, Carousel, CarouselSlideshow, CarouselWithThumbnails, CenteredStrip, CheckListItem, ComboBox, CrossfadeStage, DateComboBox, DateInput, Dialog, Drawer, DrawerWithGrip, DropdownList, ExpandablePanel, ExpandableSection, Explorer, FilterComboBox, FilterListBox, HamburgerMenuButton, Hidden, Input, ListBox, ListComboBox, ListExplorer, ListWithSearch, Menu, MenuButton, MenuItem, MenuSeparator, ModalBackdrop, Modes, MultiSelectListBox, NumberSpinBox, Option, OptionList, Overlay, OverlayFrame, PlainAlertDialog, PlainArrowDirectionButton, PlainAutoCompleteComboBox, PlainAutoCompleteInput, PlainAutoSizeTextarea, PlainBackdrop, PlainBorderButton, PlainButton, PlainCalendarDay, PlainCalendarDayButton, PlainCalendarDayNamesHeader, PlainCalendarDays, PlainCalendarMonth, PlainCalendarMonthNavigator, PlainCalendarMonthYearHeader, PlainCarousel, PlainCarouselSlideshow, PlainCarouselWithThumbnails, PlainCenteredStrip, PlainCenteredStripHighlight, PlainCenteredStripOpacity, PlainChoice, PlainComboBox, PlainCrossfadeStage, PlainDateComboBox, PlainDateInput, PlainDialog, PlainDrawer, PlainDrawerWithGrip, PlainDropdownList, PlainExpandablePanel, PlainExpandableSection, PlainExpandCollapseToggle, PlainExplorer, PlainFilterComboBox, PlainFilterListBox, PlainHamburgerMenuButton, PlainHidden, PlainInput, PlainListBox, PlainListComboBox, PlainListExplorer, PlainListWithSearch, PlainMenu, PlainMenuButton, PlainMenuItem, PlainMenuSeparator, PlainModalBackdrop, PlainModes, PlainMultiSelectListBox, PlainNumberSpinBox, PlainOptionList, PlainOverlay, PlainOverlayFrame, PlainPageDot, PlainPopup, PlainPopupButton, PlainPopupSource, PlainProgressSpinner, PlainPullToRefresh, PlainRepeatButton, PlainSelectableButton, PlainSlideshow, PlainSlideshowWithPlayControls, PlainSlidingPages, PlainSlidingStage, PlainSpinBox, PlainTabButton, PlainTabs, PlainTabStrip, PlainToast, Popup, PopupButton, PopupSource, ProgressSpinner, PullToRefresh, ReactiveElement, RepeatButton, SelectableButton, Slideshow, SlideshowWithPlayControls, SlidingPages, SlidingStage, SpinBox, TabButton, Tabs, TabStrip, Toast, TooltipButton, UpDownToggle, and WrappedStandardElement.
default State property
The default state for the component. This can be extended by mixins and classes to provide additional default state.
Type: PlainObject
render(changed) method
Render the indicated changes in state to the DOM.
The default implementation of this method does nothing. Override this method in your component to update your component's host element and any shadow elements to reflect the component's new state. See the rendering example.
Be sure to call super
in your method implementation so that your
component's base classes and mixins have a chance to perform their own
render work.
Parameters:
- changed:
ChangedFlags
– dictionary of flags indicating which state members have changed since the last render
render Changes() method
Render any pending component changes to the DOM.
This method does nothing if the state has not changed since the last render call.
ReactiveMixin will invoke this method following a setState
call;
you should not need to invoke this method yourself.
This method invokes the internal render
method, then invokes the
rendered
method.
rendered(changed) method
Perform any work that must happen after state changes have been rendered to the DOM.
The default implementation of this method does nothing. Override this
method in your component to perform work that requires the component to
be fully rendered, such as setting focus on a shadow element or
inspecting the computed style of an element. If such work should result
in a change in component state, you can safely call setState
during the
rendered
method.
Be sure to call super
in your method implementation so that your
component's base classes and mixins have a chance to perform their own
post-render work.
Parameters:
- changed:
ChangedFlags
–
set State(changes) method
Update the component's state by merging the specified changes on top of the existing state. If the component is connected to the document, and the new state has changed, this returns a promise to asynchronously render the component. Otherwise, this returns a resolved promise.
Parameters:
- changes:
PlainObject
– the changes to apply to the element's state
Returns: Promise
- resolves when the new state has been rendered
state property
The component's current state.
The returned state object is immutable. To update it, invoke
internal.setState
.
It's extremely useful to be able to inspect component state while
debugging. If you append ?elixdebug=true
to a page's URL, then
ReactiveMixin will conditionally expose a public state
property that
returns the component's state. You can then access the state in your
browser's debug console.
Type: PlainObject
state Effects(state, changed) method
Ask the component whether a state with a set of recently-changed fields implies that additional second-order changes should be applied to that state to make it consistent.
This method is invoked during a call to internal.setState
to give all
of a component's mixins and classes a chance to respond to changes in
state. If one mixin/class updates state that it controls, another
mixin/class may want to respond by updating some other state member that
it controls.
This method should return a dictionary of changes that should be applied
to the state. If the dictionary object is not empty, the
internal.setState
method will apply the changes to the state, and
invoke this stateEffects
method again to determine whether there are
any third-order effects that should be applied. This process repeats
until all mixins/classes report that they have no additional changes to
make.
See an example of how ReactiveMixin
invokes the stateEffects
to
ensure state consistency.
Parameters:
- state:
PlainObject
– a proposal for a new state - changed:
ChangedFlags
– the set of fields changed in this latest proposal for the new state
Returns: PlainObject