Status: IMPLEMENTED
Champion: Nolan Lawson
Revision: latest
RFC created: 2021/12/15
Last updated: 2022/06/16
RFC: https://github.com/salesforce/lwc/pull/2690
This RFC has been merged
# 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:
- React:
createRef()
- Vue: template refs
- Svelte:
bind:this
- Lit:
createRef()
- Angular:
elementRef
- Stencil:
ref
- Aura:
aura:id
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:ref
s
If duplicate lwc:ref
s 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:
- React: duplicates allowed, last one wins
- Vue: duplicates allowed, last one wins
- Svelte: duplicates allowed, last one wins
# 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.