Refs

Summary

This proposal offers a way to explicitly reference DOM nodes defined in a template from within a component's JavaScript.

Basic example

<!-- component.html -->
<template>
    <div lwc:ref="foo">Hello world!</div>
</template>
// component.js
import { LightningElement } from 'lwc';
export default class extends LightningElement {
  renderedCallback() {
    console.log(this.refs.foo.textContent); // 'Hello world!'
  }
}

Motivation

As of today, the only way to access DOM nodes defined in the template via JavaScript is using standard DOM APIs on this.template (shadow DOM) or this (light DOM):

// shadow DOM
this.template.querySelector('div');

// light DOM
this.querySelector('div');

This works, and it follows vanilla web component conventions, but it's cumbersome. It forces the developer to think of a query selector (or another technique, like .firstChild) to locate an element in the DOM.

In the context of light DOM components, there is an additional difficulty: this.querySelector() may return elements from outside of the immediate template (e.g. light DOM children). So developers have to choose their selectors carefully.

Prior art

The concept of "refs" exists in many other frameworks:

By building on these conventions, we offer a simple and ergonomic way to solve the problem of locating elements at runtime.

Detailed design

The basic design of refs is the use of the lwc:ref directive in templates:

<template>
    <div lwc:ref="foo"></div>
</template>

...which can be accessed at runtime in a component using this.refs:

export default class extends LightningElement {
  renderedCallback() {
    console.log(this.refs.foo); // `<div>` DOM node
  }
}

lwc:ref

lwc:ref can only refer to a static string. For instance, this will throw a template compiler error:

<template>
    <div lwc:ref={dynamic}></div> <!-- Not allowed! -->
</template>

lwc:ref cannot be applied to <template>s, or to <slot>s in light DOM:

<template>
    <template if:true={foo} lwc:ref="foo"></template> <!-- Not allowed! -->
</template>
<template lwc:render-mode="light">
    <slot lwc:ref="foo"></slot> <!-- Not allowed! -->
</template>

this.refs

this.refs is a plain object, with string keys and DOM element values. If a ref does not exist, it returns undefined. For example, then this.refs.unknown returns undefined.

The object is read-only, and attempting to add, modify, or delete its properties from within the component will result in a runtime error.

In the case of shadow DOM components, this.refs refers to elements inside of the shadow DOM. Whereas with light DOM components, it refers to elements inside of the light DOM. This makes sense, since in either case, the element must be defined in the template owned by that component in order to be accessible with this.refs.

To preserve backwards compatibility with existing components that may have defined a refs property, refs will be configurable and writable, so if a component defines its own refs, it will replace the one from LightningElement.prototype.

To further improve backwards compatibility, the refs property will only be defined if the directive lwc:ref is present somewhere in the template. If lwc:ref is not defined in the template, then this.refs returns undefined.

In the case of a component that renders multiple templates, the presence or absence of lwc:ref is checked on-demand in the this.refs getter. So whether this.refs is undefined or an object will depend on the current template when the this.refs getter is invoked.

Multiple templates

In the case of multiple templates, the same rules as for this.template.querySelector() or this.querySelector() apply. Once the new template is rendered, this.refs refers to elements defined in the new template:

import a from './a.html';
import b from './b.html';

export default class extends LightningElement {
  count = 0;
  
  render() {
    return this.count % 2 === 0 ? a : b;
  }
  
  renderedCallback() {
    console.log(this.refs);
  }
  
  increment () {
    this.count++;
  }
}
const cmp = createElement('x-component', { is: Component });
// Logs `this.refs` for a.html

cmp.increment();
// Logs `this.refs` for b.html

cmp.increment();
// Logs `this.refs` for a.html

Duplicate lwc:refs

If duplicate lwc:refs are defined in the same template:

<template>
    <div lwc:ref="foo"></div>
    <div lwc:ref="foo"></div>
</template>

... then the second one "wins" (in terms of depth-first tree order). The reason for allowing duplicates is explained below.

Conditional elements

In some cases, it may be useful to have elements that are conditionally defined based on <template if:true>. For example:

<template>
    <template if:true={darkMode}>
        <div lwc:ref="foo"></div>      
    </template>
    <template if:false={darkMode}>
        <div lwc:ref="foo"></div>
    </template>
</template>

In this case, it's convenient to be able to call this.refs.foo and to not care whether the element is inside the first <template> or the second.

Because the template compiler cannot know which conditions will cause an element to exist or not, we cannot support this use case while also disallowing duplicates. Therefore, the simplest solution for duplicates is to define whichever element is last (in terms of depth-first tree order) to be the one that "wins."

Here is how other frameworks do it:

Loops

lwc:ref inside of a for:each or iterator:* loop should throw an error:

<template for:each={items} for:item="item">
    <div key={item} lwc:ref="foo"></div> <!-- Not allowed -->
</template>

In the future, we may support some kind of equivalent of v-for array refs to collect multiple elements in an array. But it is not supported in the current proposal.

Drawbacks

This feature may cause some confusion over what is the "best" way to access DOM elements in a component – querySelector (and other DOM APIs) or refs.

Also, refs are non-standard, whereas querySelector and friends are standard.

Alternatives

The impact of not doing this is that it's much trickier to find the right elements, in light DOM in particular.

Adoption strategy

This is potentially a breaking change in a very edge-casey scenario where the component relies on this.refs not being defined in advance. For instance:

export class Example extends LightningElement {
  renderedCallback() {
    if (this.refs) {  // <-- Will always return true
      this.refs.div.textContent += '[updated]!';
    } else {
      this.refs = { div: this.template.querySelector('div') };
    }
  }
}

This use case is probably not very common, though.

Also, there is no need to replace existing uses of querySelector / querySelectorAll / etc. Developers are free to adopt refs incrementally as they write new components, or to modify existing components if they wish.

A codemod may be tricky, but not impossible, to implement. (Search for existing usages of querySelector, search the template for a matching element, replace with refs.)

How we teach this

This feature is very similar to the same concept in other frameworks (React, Vue, etc.). So we can leverage that familiarity when teaching this.

One benefit of teaching refs over querySelector is that there is no difference between light DOM and shadow DOM. I.e. we don't have to explain "Use this.template.querySelector for shadow DOM, but this.querySelector for light DOM." Considering that this.template is kind of a misnomer (it refers to the shadow root, not the template), it can only improve teachability to avoid it, and to have one system for both shadow and light components.

Unresolved questions

None at this time.

undefined