4

I'm creating a set of LWR components that can be added to a page declaratively in Experience Builder. Since these components aren't hard-coded to the top-level component, based on a recommendation from this thread, I'm using a shared import approach to communicate and share data and logic between the components. When components are rendered and when changes occur, the shared object is updated. However, DOM elements that I would like to be re-rendered based on changes to the shared object don't reflect those changes.

This example illustrates what I'm doing and the issue I'm having:

appModel.js

class Model {
  inputs = {};
}

export const model = new Model(); // This is the shared object variable

app.html

<template>
    <x-input label="First Name" required></x-input>
    <x-input label="Last Name" required></x-input>
    <button disabled={isSubmitDisabled} onclick={handleSubmit}>Submit</button>
</template>

app.js

import { LightningElement } from 'lwc';
import { model } from 'x/appModel'; // The shared variable is imported here

export default class App extends LightningElement {

  // This evaluates to false initially and never re-evaluates after model.inputs changes
  get isSubmitDisabled() {
    return Object.values(model.inputs).some((input) => input.isValid === false);
  }

  handleSubmit = () => {
    console.log(JSON.parse(JSON.stringify(model.inputs)));
    // Here I can see that the inputs object was updated as expected:
    // { 
    //   "First Name": { value: "", isValid: false },
    //   "Last Name": { value: "", isValid: false }
    // }
  }
}

input.html

<template>
  <label for={label}>
    {label}
    <span lwc:if={required}>*</span>
  </label>
  <input required={required} onchange={updateModel} />
</template>

input.js

import { LightningElement, api } from 'lwc';
import { model } from 'x/appModel'; // The shared variable is imported here too

export default class Input extends LightningElement {
  @api label;
  @api required = false;
  hasBeenRenderedOnce = false;

  renderedCallback() {
    if (!this.hasBeenRenderedOnce) this.updateModel();
    this.hasBeenRenderedOnce = true;
  }

  updateModel = () => {
    const input = this.template.querySelector('input');
    model.inputs[this.label] = {
      value: input.value,
      isValid: input.checkValidity(),
    }; // Shared variable is updated on render and when input values change
  };
}

StackBlitz Demo

Although a console log shows that the inputs object has 2 values where isValid === false, get isSubmitDisabled() does not re-evaluate after the custom <x-input> components execute their updateModel logic. The <button> element remains enabled.

Even if I track the inputs object like this, I still have the same problem -- the Submit button remains enabled.

app.js

import { LightningElement, track } from 'lwc';
import { model } from 'x/appModel';

export default class App extends LightningElement {
  @track inputs = model.inputs; // Tracking the object here

  // This still evaluates to false initially and never re-evaluates
  get isSubmitDisabled() {
    return Object.values(this.inputs).some((input) => input.isValid === false);
  }
  ...
}

I like this shared import approach overall. It's just the re-render that's lacking. How can I work around this problem?

3
  • Maybe the parent should be the only one importing and sharing the model. Then it can be distributed between children as an @api attribute which will mean changes should be tracked Apr 4, 2023 at 19:01
  • Plus changes to the model would all come only from the parent - child would despatch event, parent would modify model and it would filter down Apr 4, 2023 at 19:05
  • 1
    That's how I've done it in the past. I did mention that in this situation, child components aren't hard-coded to the top-level component, they're dragged and dropped using Experience Builder. Maybe I should add that there is a slot hierarchy -- components are dragged into components, which are in turn dragged into other components, to an unknown depth. Bottom line, the parent component can't querySelectorAll for all its descendents. Apr 4, 2023 at 19:12

1 Answer 1

4

OK, I figured out a way with help from this StackOverflow answer.

We can use a Proxy for the inputs object in the model, defining a custom setter for that proxy. The setter calls one or more listener functions, which are passed in by components that want to subscribe to changes. The listener functions set the values on their respective component properties.

I changed these files:

appModel.js

const inputChangeListeners = [];

class Model {
  _inputs = {}
  inputs = new Proxy(this._inputs, {
    set: function (target, key, value) {
      target[key] = value;
      inputChangeListeners.forEach((listener) => listener())
      return true;
    }
  });
  registerInputChangeListener = (listener) => {
    inputChangeListeners.push(listener);
  }
}

export const model = new Model();

app.js

import { LightningElement } from 'lwc';
import { model } from 'x/appModel';

export default class App extends LightningElement {
  isSubmitDisabled = false;

  connectedCallback() {
    model.registerInputChangeListener(this.setIsSubmitDisabled);
  }

  setIsSubmitDisabled = () => {
    this.isSubmitDisabled = Object.values(model.inputs).some(
      (input) => input.isValid === false
    );
  };

  ...

}

StackBlitz Demo

1
  • 2
    That's about what I was writing up, actually. Great job!
    – sfdcfox
    Apr 4, 2023 at 19:03

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .