Of Stale UI and Re-renders

Dated May 28, 2024; last modified on Tue, 28 May 2024

More than once, I’ve been surprised by a web component either showing data that should no longer be there, or not showing data that should be there. This page aims to reason through such cases for a better mental model of web components.

Rendering Lists

Both <search-bar> and <search-results> need to render a collection of N items. offers two options: looping, or using the repeat(items, keyFunction, itemTemplate) directive.

const cards = html`
  ${cards.map(
    (card) => html`<div><input type="checkbox">${card.title}</div>`
  )}
`;

const template2 = html`
  ${repeat(
    cards, (card) => card.id
    (card) => html`<div><input type="checkbox">${card.id}</div>`
  )}
`;

When performing updates, repeat moves DOM nodes, while map reuses DOM nodes. This is also beneficial when there is some part of the node that isn’t controlled by a template expression because repeat will keep that state, e.g, in the example above, the checked property. If none of these apply, then map or loops can be used over repeat.

While React does not have a dedicated repeat equivalent, React issues a Warning: Each child in a list should have a unique "key" prop when rendering a list without specifying keys for the individual items.

The recommended behaviors in Lit and React make it seem like using keys is almost always the correct approach to use. We’d need perf numbers to justify not using keys.

Re-renders on Tagged Templates

Given a component like:

@customElement('sample-app')
export class SampleApp extends LitElement {
  @state() private content = 'Hello world';
  @state() private counter = 0;

  constructor() {
    super();
    setInterval(() => this.counter++, 1000);
  }

  render() {
    return html`<p>${this.content}</p>`;
  }
}

SampleApp.render will be called every second . The fact that this.counter is not referenced in the rendered template does not prevent an update cycle. In this case, this.counter need not be reactive; a vanilla instance variable will do.

In this example though:

function getTimeString() {
  let now = new Date(Date.now());
  return `${now.getMinutes()}:${now.getSeconds()}`;
}

@customElement('input-wrapper')
class InputWrapperElement extends LitElement {
  render() {
    return html`<input value=${getTimeString()} >`;
  }
}

@customElement('sample-app')
export class SampleApp extends LitElement {
  @state() content = 'Hello world';

  constructor() {
    super();
    setInterval(this.updateContent.bind(this), 1000);
  }

  render() {
    return html`
      <div>Expected value: ~${this.content}</div>
      <input-wrapper></input-wrapper>
    `;
  }

  private updateContent() {
    this.content = getTimeString();
  }
}

<input> does not update every second . This might be because <sample-app> decides that <input-wrapper> should not be updated.

What if we want to reset <input> from SampleApp.updateContent()? Exposing InputWrapperElement.reset() could do the trick, but that breaks away from the “events go up; properties come down” philosophy for encapsulated web components. Syncing InputWrapperElement.value with the <input>’s value does not work either.

References

  1. Lists – Lit. lit.dev . Accessed May 28, 2024.
  2. Rendering Lists – React. react.dev . Accessed May 28, 2024.
  3. Lit Playground - Updating State. lit.dev . Accessed Jun 2, 2024.
  4. Lit Playground - Wrapped Input Element. lit.dev . Accessed Jun 2, 2024.
  5. Lit Playground - Wrapped Input Synced Props. lit.dev . Accessed Jun 2, 2024.