Note: If you’d like to try it out live first, here is a sandbox example.
I like to use the Lit framework to create self-contained web components that provide composable drop-in functionality. I choose them over Stimulus controllers when I know that I will use them in the exact same way in a lot of places, and the behavior they exhibit doesn’t “leak” to other places of the DOM (in the way a Stimulus modal controller touches several places of the DOM at once). Dependency Injection a la HTML, so to speak. A good example is an InlineEditElement
:
import { LitElement } from "lit";
import { customElement, state, property } from "lit/decorators.js";
@customElement("inline-edit")
export default class InlineEditElement extends LitElement {
@state()
_armed = false;
@property({type: String})
value: string;
_arm() {
this._armed = true;
}
_save(value) {
// call InlineEditReflex (?)
}
_handleKeydown(event) {
if (event.key === "Enter") {
event.preventDefault();
this._save(event.target.value);
}
}
render() {
return this._armed
? html`<input
@keydown="${this._handleKeydown}"
type="text"
value="${this.value}"
/>`
: html`<span @click="${this._arm}">${this.value}</span>`;
}
}
If you look at the render
method in the example above, it defines a template whose content depends on the internal _armed
state (React savvy folks will find this oddly familiar). By clicking on the text, this._arm
is called and the state is switched - an <input>
element is rendered. By hitting Enter when the input element is focused, we’d like to call _save
and trigger an InlineEditReflex
that looks as follows.
class InlineEditReflex < ApplicationReflex
def save(content)
# persist...
end
end
So much for our desires, but currently there’s no way to stimulate
a reflex directly from within a Lit component. There’s an elegant workaround though: We can create a transparent proxy that mediates reflex calls back and forth between Reflex and our component:
// app/javascript/controllers/reflex_proxy_controller.js
import ApplicationController from "./application_controller";
export default class extends ApplicationController {
initialize() {
this.stimulateHandler = this.handleStimulate.bind(this);
}
connect() {
super.connect();
this.element.addEventListener("stimulate", this.stimulateHandler);
}
disconnect() {
this.element.removeEventListener("stimulate", this.stimulateHandler);
}
async handleStimulate({ detail }) {
this.stimulate(
detail.reflex,
this.element,
{
resolveLate: true,
},
...(detail.args || [])
).then(({ payload }) => {
if (!detail.caller) return;
this.dispatch(detail.caller, { prefix: "afterProxy", detail: payload });
});
}
}
We implement a handleStimulate
listener that relays stimulate calls along with any arguments to this.stimulate
, waits for the promise to resolve and notify the caller (we’ll get to that in a minute). We can then tack this controller onto any element really, like so:
<inline-edit data-controller="reflex-proxy" id="some-sgid" value="<%= @value %>"></inline-edit>
What is missing is some glue code that allows our InlineEditElement
to send events to this controller. Because this sort of functionality could be interesting for any component I create, I’ve extracted it to a Lit mixin:
// app/javascript/components/mixins/reflex-proxy-mixin.js
const ReflexProxyMixin = (superClass) =>
class extends superClass {
afterProxy(caller, fn) {
this.addEventListener(`afterProxy:${caller}`, fn, {
once: true,
});
}
stimulate(reflex, args, caller) {
this.dispatchEvent(
new CustomEvent("stimulate", {
detail: { reflex, args, caller },
})
);
}
};
export default ReflexProxyMixin;
Essentially, this adds a stimulate
method which dispatches the event of the same name, and puts the name of the reflex, any arguments, and a caller
id into the event.detail
. The second method it attaches to the mixed-in class is afterProxy
, which allows to add an event listener to the element in a decoupled fashion, mimicking a lifecycle callback. If you peek into the definition of the ReflexProxyController
above, it dispatches the respective event (with the optional payload
from the reflex attached) back to the element.
Put together, this is what this looks like:
import { LitElement } from "lit";
import { customElement, state, property } from "lit/decorators.js";
import ReflexProxyMixin from "./mixins/reflex-proxy-mixins.js";
@customElement("inline-edit")
export default class InlineEditElement extends ReflexProxyMixin(LitElement) {
@state()
_saving = false;
@state()
_armed = false;
@property({type: String})
value: string;
_arm() {
this._armed = true;
}
_save(value) {
if (this._saving) return;
this._saving = true;
this.stimulate("InlineEdit#save", [value], `inline-edit-${this.id}`);
this.afterProxy(`inline-edit-${this.id}`, () => {
this._armed = false;
this._saving = false;
});
}
_handleKeydown(event) {
if (event.key === "Enter") {
event.preventDefault();
this._save(event.target.value);
}
}
render() {
return this._armed
? html`<input
@keydown="${this._handleKeydown}"
type="text"
value="${this.value}"
/>`
: html`<span @click="${this._arm}">${this.value}</span>`;
}
}
In save
, we now stimulate a Reflex just like we’re used to, with a caller
id attached so only ever the correct element gets notified:
this.stimulate("InlineEdit#save", [value], `inline-edit-${this.id}`);
We then install an afterProxy
handler to clear the _armed
and _saving
state after the reflex has completed (Note that the _saving
state has been added as a guard to return early if a save is currently happening, thus avoiding race conditions):
this.afterProxy(`inline-edit-${this.id}`, () => {
this._armed = false;
this._saving = false;
});
And that’s it! Now you can use StimulusReflex inside every Lit component you want. A few edge cases of this.stimulate
haven’t been covered, but this example certainly serves as a starting points for further explorations of yours 🧑🔬.