DEV Community

julianrubisch
julianrubisch

Posted on • Originally published at blog.minthesize.com on

Use a Lit ReflexProxy to Wire Up your Web Components with StimulusReflex

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>`;
  } 
}

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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 });
    });
  }
}

Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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>`;
  } 
}

Enter fullscreen mode Exit fullscreen mode

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}`);

Enter fullscreen mode Exit fullscreen mode

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;
});

Enter fullscreen mode Exit fullscreen mode

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 🧑‍🔬.

Discussion (0)