Reactive properties in LWC

Reactive properties in LWC : All fields are reactive. If the value of a field changes and the field is used in a template or in the getter of a property used in a template, the component re-renders and the renderedCallback() lifecycle hook is called. When a component re-renders, all the expressions used in the template are re-evaluated.

To make a field public and therefore available to a component’s consumers as a property, decorate it with @api.

Field and property are almost interchangeable terms. A component author declares fields in a class. An instance of the class has properties, so to component consumers, a field is a property. In a Lightning web component, only fields that a component author decorates with @api are publicly available to consumers as object properties.

Decorators are a JavaScript language feature. The @api and @track decorators are unique to Lightning Web Components. A field can have only one decorator.

Public Property Reactivity

To expose a public property, decorate a field with @api. Public properties define the API for a component. An owner component that uses the component in its HTML markup can access the component’s public properties via HTML attributes.

If the value of a public property changes, the component’s template re-renders.

todoApp.js

Let’s look at a simple app in the playground. The example-todo-item component has a public itemName property. The example-todo-app component consumes example-todo-item and sets its property via the item-name attribute.

Property names in JavaScript are in camel case while HTML attribute names are in kebab case (dash-separated) to match HTML standards. In the example-todo-app template, the item-name attribute on example-todo-item maps to the itemName JavaScript property on example-todo-item.

In todoApp.html, change the value of the item-name attributes and watch the component re-render.

import { LightningElement, api } from 'lwc';
export default class TodoApp extends LightningElement {}

todoApp.html

<template>
    <example-todo-item item-name="Milk"></example-todo-item>
    <example-todo-item item-name="Bread"></example-todo-item>
</template>

todoItem.js

import { LightningElement, api } from 'lwc';
export default class TodoItem extends LightningElement {
    @api itemName;
}

todoItem.html

<template>
    <div>{itemName}</div>
</template>

Let’s walk through the code.

The TodoItem class imports the @api decorator from lwc. It declares an itemName field and decorates it with @api to make it public. This class is part of the example-todo-item component, where example is the namespace.

// todoItem.js
import { LightningElement, api } from 'lwc';
export default class TodoItem extends LightningElement {
    @api itemName = 'New Item';
}

The component’s template defines a single todo item.

<!-- todoItem.html -->
<template>
    <div>
        <label>{itemName}</label>
    </div>
</template>

A parent component, in this case example-todo-app, can set the itemName property on child example-todo-item components.

<!-- todoApp.html -->
<template>
    <div>
        <example-todo-item item-name="Milk"></example-todo-item>
        <example-todo-item item-name="Bread"></example-todo-item>
    </div>
</template>

The parent component can also access and set the itemName property in JavaScript.

// todoApp.js
const myTodo = this.template.querySelector('example-todo-item');
myTodo.itemName // New Item

Field Reactivity

All fields are reactive. When the framework observes a change to a field used in a template or used in the getter of a property used in a template, the component re-renders.

In this example, the firstName and lastName fields are used in the getter of the uppercasedFullName property, which is used in the template. When either field value changes, the component re-renders.

helloExpressions.js

import { LightningElement } from 'lwc';

export default class HelloExpressions extends LightningElement {
    firstName = '';
    lastName = '';

    handleChange(event) {
        const field = event.target.name;
        if (field === 'firstName') {
            this.firstName = event.target.value;
        } else if (field === 'lastName') {
            this.lastName = event.target.value;
        }
    }

    get uppercasedFullName() {
        return `${this.firstName} ${this.lastName}`.trim().toUpperCase();
    }
}

helloExpressions.html

<template>
    <div>
        <ui-input
            name="firstName"
            label="First Name"
            onchange={handleChange}
        ></ui-input>
        <ui-input
            name="lastName"
            label="Last Name"
            onchange={handleChange}
        ></ui-input>
        <p class="margin-top-medium">
            Uppercased Full Name: {uppercasedFullName}
        </p>
    </div>
</template>

input.js

import { LightningElement, api, track } from 'lwc';

export default class Input extends LightningElement {
    @api
    set checked(val) {
        this._checked = val;
    }
    get checked() {
        return this._checked;
    }
    @api disabled;
    @api hasClearButton;
    @api label;
    @api max;
    @api min;
    @api name;
    @api type = 'text';
    @api
    set value(val) {
        this.valuePrivate = this._value = val !== undefined ? val : '';
    }
    get value() {
        return this._value;
    }

    @track valuePrivate = '';

    _checked = false;

    changeHandler() {
        this._checked = !this._checked;
        this.dispatchEvent(new CustomEvent('change'));
    }

    keyupHandler(event) {
        this._value = event.target.value;
        this.dispatchEvent(new CustomEvent('change'));
    }

    handleClearClick() {
        this._value = '';
        this.dispatchEvent(new CustomEvent('change'));
    }

    get isCheckboxField() {
        return this.type === 'checkbox';
    }

    get isNumberField() {
        return this.type === 'number';
    }

    get isSearchField() {
        return this.type === 'search';
    }

    get isTextField() {
        return this.type === 'text' || this.type === 'search';
    }

    get calculatedClassFormElement() {
        let classSet = this.getAttribute('class');
        if (!classSet) {
            classSet = ['slds-form-element__control'];
        } else {
            classSet = classSet.split(' ');
            classSet.push('slds-form-element__control');
        }
        if (this.type === 'search') {
            classSet.push('slds-input-has-icon slds-input-has-icon_left-right');
        }
        return classSet.join(' ');
    }

    get calculateClassInput() {
        let classSet = [this.type !== 'checkbox' ? 'slds-input' : 'slds-checkbox'];
        if (this.disabled) {
            classSet.push('slds-is-disabled');
        }
        return classSet.join(' ');
    }
}

input.html

<template>
    <div>
      <template if:false={isCheckboxField}>
        <label>{label}</label>
      </template>
      <div class={calculatedClassFormElement}>
        <template if:false={isCheckboxField}>
          <template if:true={isSearchField}>
            <svg aria-hidden="true">
              <use
                xlink:href="/assets/icons/utility-sprite/svg/symbols.svg#search"
              ></use>
            </svg>
          </template>
          <input
            class={calculateClassInput}
            type={type}
            value={valuePrivate}
            onkeyup={keyupHandler}
            onchange={changeHandler}
          />
          <template if:true={hasClearButton}>
            <button
              title="Clear"
              onclick={handleClearClick}
            >
              <svg
                aria-hidden="true"
              >
                <use
                  xlink:href="/assets/icons/utility-sprite/svg/symbols.svg#clear"
                ></use>
              </svg>
              <span>Clear</span>
            </button>
          </template>
        </template>
        <template if:true={isCheckboxField}>
          <div class="slds-checkbox">
            <!-- TODO: Checkbox not longer working -->
            <input
              class={calculateClassInput}
              type="checkbox"
              value={valuePrivate}
              onchange={changeHandler}
            />
            <label>
              <span></span>
              <span>{label}</span>
            </label>
          </div>
        </template>
      </div>
    </div>
  </template>

The firstName and lastName fields contain primitives.

firstName = '';
lastName = '';

There is a limit to the depth of changes that the framework observes. If a field contains an object value or an array value, the framework observes changes that assign a new value to the field. If the value that you assign to the field is not === to the previous value, the component re-renders. To tell the framework to observe changes to the properties of an object or to the elements of an array, decorate the field with @track.

Let’s declare the fullName field, which contains an object with two properties. The framework observes changes that assign a new value to the field.

fullName = { firstName : '', lastName : ''};

This code changes the value of fullName, so the component re-renders.

this.fullName = { firstName: 'Jane', lastName: 'Doe' };

This code changes the value of firstName. The framework is not observing changes to firstName, so the component doesn’t re-render. Remember, the framework is observing changes to the fullName field. This code doesn’t assign a new value to fullName, instead it assigns a value to the firstName property.

this.fullName.firstName = 'Jane';

To tell the framework to observe changes to the properties of the fullName object, decorate the field with @track.

@track fullName = { firstName: '', lastName: '' };

Now when the firstName property changes, the component re-renders.

this.fullName.firstName = 'Jane';

Let’s look at this code in the playground. Enter a first name and last name and watch the component re-render. Now remove @track and do the same. The component doesn’t re-render.

helloExpressionsTrack.js

import { LightningElement, track } from 'lwc';

export default class HelloExpressionsTrack extends LightningElement {
    @track fullName = { firstName: '', lastName: '' };

    handleChange(event) {
        const field = event.target.name;
        if (field === 'firstName') {
            this.fullName.firstName = event.target.value;
        } else if (field === 'lastName') {
            this.fullName.lastName = event.target.value;
        }
    }

    get uppercasedFullName() {
        return `${this.fullName.firstName} ${this.fullName.lastName}`.trim().toUpperCase();
    }
}

helloExpressionsTrack.js

<template>
    <div>
        <ui-input
            name="firstName"
            label="First Name"
            onchange={handleChange}
        ></ui-input>
        <ui-input
            name="lastName"
            label="Last Name"
            onchange={handleChange}
        ></ui-input>
        <p class="margin-top-medium">
            Uppercased Full Name: {uppercasedFullName}
        </p>
    </div>
</template>

input.js

import { LightningElement, api, track } from 'lwc';

export default class Input extends LightningElement {
    @api
    set checked(val) {
        this._checked = val;
    }
    get checked() {
        return this._checked;
    }
    @api disabled;
    @api hasClearButton;
    @api label;
    @api max;
    @api min;
    @api name;
    @api type = 'text';
    @api
    set value(val) {
        this.valuePrivate = this._value = val !== undefined ? val : '';
    }
    get value() {
        return this._value;
    }

    @track valuePrivate = '';

    _checked = false;

    changeHandler() {
        this._checked = !this._checked;
        this.dispatchEvent(new CustomEvent('change'));
    }

    keyupHandler(event) {
        this._value = event.target.value;
        this.dispatchEvent(new CustomEvent('change'));
    }

    handleClearClick() {
        this._value = '';
        this.dispatchEvent(new CustomEvent('change'));
    }

    get isCheckboxField() {
        return this.type === 'checkbox';
    }

    get isNumberField() {
        return this.type === 'number';
    }

    get isSearchField() {
        return this.type === 'search';
    }

    get isTextField() {
        return this.type === 'text' || this.type === 'search';
    }

    get calculatedClassFormElement() {
        let classSet = this.getAttribute('class');
        if (!classSet) {
            classSet = ['slds-form-element__control'];
        } else {
            classSet = classSet.split(' ');
            classSet.push('slds-form-element__control');
        }
        if (this.type === 'search') {
            classSet.push('slds-input-has-icon slds-input-has-icon_left-right');
        }
        return classSet.join(' ');
    }

    get calculateClassInput() {
        let classSet = [this.type !== 'checkbox' ? 'slds-input' : 'slds-checkbox'];
        if (this.disabled) {
            classSet.push('slds-is-disabled');
        }
        return classSet.join(' ');
    }
}

input.html

<template>
    <div>
      <template if:false={isCheckboxField}>
        <label>{label}</label>
      </template>
      <div class={calculatedClassFormElement}>
        <template if:false={isCheckboxField}>
          <template if:true={isSearchField}>
            <svg aria-hidden="true">
              <use
                xlink:href="/assets/icons/utility-sprite/svg/symbols.svg#search"
              ></use>
            </svg>
          </template>
          <input
            class={calculateClassInput}
            type={type}
            value={valuePrivate}
            onkeyup={keyupHandler}
            onchange={changeHandler}
          />
          <template if:true={hasClearButton}>
            <button
              title="Clear"
              onclick={handleClearClick}
            >
              <svg
                aria-hidden="true"
              >
                <use
                  xlink:href="/assets/icons/utility-sprite/svg/symbols.svg#clear"
                ></use>
              </svg>
              <span>Clear</span>
            </button>
          </template>
        </template>
        <template if:true={isCheckboxField}>
          <div class="slds-checkbox">
            <!-- TODO: Checkbox not longer working -->
            <input
              class={calculateClassInput}
              type="checkbox"
              value={valuePrivate}
              onchange={changeHandler}
            />
            <label>
              <span></span>
              <span>{label}</span>
            </label>
          </div>
        </template>
      </div>
    </div>
  </template>

Reactive Data Types

There is a limit to the depth of changes that the framework observes. The depth depends on the data type of the field.

Note

Fields are reactive. Expandos are not reactive.

Lightning Web Components observes changes to the internal values of these types of fields:

  • Primitive values
  • Plain objects created with {…} and decorated with @track
  • Arrays created with [] and decorated with @track

If a field contains a primitive data type, reactivity simply works as expected.

If a field contains an object value or an array value, it’s important to understand how the framework tracks changes. As we saw in Field Reactivity, the framework doesn’t observe changes to the properties of an object or to the elements of an array unless the field is decorated with @track.

Let’s look at a component with a field, x, of type Date. The template has a few buttons that change the internal state of x. This example highlights that new Date() creates an object, but not via {}, so the internals of the object aren’t observed, even though the code uses @track.

trackDate.js

import { LightningElement, track } from 'lwc';
export default class TrackDate extends LightningElement {
    @track x;

    initDate() {
        this.x = new Date();
    }

    updateDate() {
        this.x.setHours(7);
    }
}

tackDate.html

<template>
    <p>Date: {x}</p>

    <button onclick={initDate}>Init</button>
    <button onclick={updateDate}>Update</button>
</template>

When you click the Init button, the x field is assigned a new Date object and the template re-renders. However, when you click Update, the template doesn’t re-render because the framework doesn’t track changes to the value of the Date object.