Lit-elements, the idiomatic way to write a controlled component
Asked Answered
P

2

6

I'm working with lit-elements via @open-wc and is currently trying to write a nested set of components where the inner component is an input field and some ancestor component has to support some arbitrary rewrite rules like 'numbers are not allowed input'.

What I'm trying to figure out is what the right way to built this is using lit-elements. In React I would use a 'controlled component' see here easily forcing all components to submit to the root component property.

The example below is what I've come up with using Lit-Elements. Is there a better way to do it?

Please note; that the challenge becomes slightly harder since I want to ignore some characters. Without the e.target.value = this.value; at level-5, the input elmement would diverge from the component state on ignored chars. I want the entire chain of components to be correctly in sync, hence the header tags to exemplify.

export class Level1 extends LitElement {
  static get properties() {
    return {
      value: { type: String }
    };
  }

  render() {
    return html`
      <div>
        <h1>${this.value}</h1>
        <level-2 value=${this.value} @input-changed=${this.onInput}></level-2>
      </div>`;
  }

  onInput(e) {
    this.value = e.detail.value.replace(/\d/g, ''); 
  }
}

...

export class Level4 extends LitElement {
  static get properties() {
    return {
      value: { type: String }
    };
  }

  render() {
    return html`
      <div>
        <h4>${this.value}</h4>
        <level-5 value=${this.value}></level-5>
      </div>`;
  }
}

export class Level5 extends LitElement {
  static get properties() {
    return {
      value: { type: String }
    };
  }

  render() {
    return html`
      <div>
        <h5>${this.value}</h5>
        <input .value=${this.value} @input=${this.onInput}></input>
      </div>`;
  }

  onInput(e) {
    let event = new CustomEvent('input-changed', {
      detail: { value: e.target.value },
      bubbles: true,
      composed: true
    });

    e.target.value = this.value;
    this.dispatchEvent(event);
  }
}

export class AppShell extends LitElement {
  constructor() {
    super();
    this.value = 'initial value';
  }

  render() {
    return html`
      <level-1 value=${this.value}></level-1>
    `;
  }
}

Added later

An alternative approach was using the path array in the event to access the input element directly from the root component.

I think it's a worse solution because it results in a stronger coupling accross the components, i.e. by assuming the child component is an input element with a value property.

  onInput(e) {
    const target = e.path[0]; // origin input element
    this.value = e.path[0].value.replace(/\d/g, ''); 
    // controlling the child elements value to adhere to the colletive state
    target.value = this.value;
  }
Phosphorylase answered 20/11, 2019 at 20:1 Comment(1)
Sounds like something you could do by having one mixin used for all yor componenets. That mixin would check if the parent has a validation callback and if so call it before it's own (this way making it a recursive process).Campagna
P
1

Don't compose your events, handle them in the big parent with your logic there. Have the children send all needed info in the event, try not to rely on target in the parent's event handler.

To receive updates, have your components subscribe in a shared mixin, a la @mishu's suggestion, which uses some state container (here, I present some imaginary state solution)

import { subscribe } from 'some-state-solution';
export const FormMixin = superclass => class extends superclass {
  static get properties() { return { value: { type: String }; } } 
  connectedCallback() {
    super.connectedCallback();
    subscribe(this);
  }
}

Then any component-specific side effects you can handle in updated or the event handler (UI only - do logic in the parent or in the state container)

import { publish } from 'some-state-solution';
class Level1 extends LitElement {
  // ...
  onInput({ detail: { value } }) {
    publish('value', value.replace(/\d/g, '')); 
  } 
}
Perrault answered 10/12, 2019 at 14:46 Comment(3)
That's not as easy in this case, I need to affect original DOM element and the way web components work, you can't just react to a change in an input field in an ancestor and then expect the change to propagate into the DOM again (the components does update though). Then you need to consider force updates which is another issue.Phosphorylase
If you can use the example above to prove me wrong, I'd redact my statement of course. ^_^Phosphorylase
You can use the event.composedPath() method to get the initiating <input>. That being said, this ties your form's model to the DOM tightly, which is brittle. You might consider using something like a finite state machine or some other kind of state store, like a Proxy or even a Redux store. Input components would fire events concerning their data only, the parent would catch them and update the model or transition the state, and changes would propagate down via component methods. If I had to couple app to DOM structure like that, I'd rather do it in one place: the parent component.Perrault
T
1

If I understand it correctly, the issue is your state ownership. You generally want only one element to have the ownership of a certain value. In this case I feel like you want it to be the AppShell component, since it's the one that has the value member (but you also want to declare it as a state, if you're using the latest Lit version). All the other components down the line should give up the ownership and just propagate the value via properties/attributes, and its changes via events.

The main issue here is you're doing e.target.value = this.value; in Level5.onInput, which is kind of giving the ownership back to input element. That's not what you want to do with a "controlled component" (to use React jargon).

Level5 should look something like this:

export class Level5 extends LitElement {
  static get properties() {
    return {
      value: { type: String }
    };
  }

  render() {
    return html`
      <div>
        <h5>${this.value}</h5>
        <input value=${this.value} @input=${this.onInput}></input>
      </div>`;
  }

  onInput(e) {
    let event = new CustomEvent('input-changed', {
      detail: { value: e.target.value },
      bubbles: true,
      composed: true
    });

    this.dispatchEvent(event);
  }
}

I removed input state manipulation. Instead input always reflects the value property, because you're passing it in the render method (also you should be passing it via attribute, not property setter (hence I omitted the .)

Levels 2 through 4 can stay as they are, but Level 1 can't be doing this.value = e.detail.value.replace(/\d/g, ''), because it doesn't own the state. What it can do it to consume the event and propagate an altered value in its own event. Something like this:

export class Level1 extends LitElement {
  static get properties() {
    return {
      value: { type: String }
    };
  }

  render() {
    return html`
      <div>
        <h1>${this.value}</h1>
        <level-2 value=${this.value} @input-changed=${this.onInput}></level-2>
      </div>`;
  }

  onInput(e) {
    e.stopPropagation();

    const newValue = e.detail.value.replace(/\d/g, '');

    let newEvent = new CustomEvent('input-changed', {
      detail: { value: newValue },
      bubbles: true,
      composed: true
    });

    this.dispatchEvent(newEvent);
  }
}

And finally you'd have the AppState to own the value.

export class AppShell extends LitElement {
  constructor() {
    super();
    this.value = 'initial value';
  }

  static get properties() {
    return {
      value: { state: true }
    };
  }

  render() {
    return html`
      <level-1 value=${this.value}></level-1>
    `;
  }
}

I would personally do any sort of validation in the same element that owns the state, but technically you can also do this kind of event interception, but it feels like it might be harder to debug. Important thing is that if the components receives a value via an attribute/property ("from the top"), it shouldn't try to modify it.

Turnedon answered 10/12, 2023 at 22:22 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.