Skip to main content

Component state and API

If you are writing your own web components, there are three pieces of terminology it's important to understand:

  • Attributes. You define these are defined in the HTML markup of the element.
  • Properties. These are accessed via the DOM object in Javascript (or via a special property-binding syntax in the mark-up).
  • Observables. Observable properties and attributes configure the component to watch for changes, enabling the associated html template to update as they are updated.
info

It is vital that you know the basics of attributes and properties in the browser. Click on the button below to learn about these concepts before you move on.

Attributes

To create an attribute, use the @attr decorator.

Here is an example of an attribute for the MyButton example component:

@customElement({
name: "my-button",
template: html<MyButton>`<button>Click Me</button>`,
styles,
})
export class MyButton extends GenesisElement {
@attr label: string = "Submit";

labelChanged(oldValue, newValue) {
console.log(`Button text changed from ${oldValue} to ${newValue}`);
}
}
property-attribute pairs

When you use the @attr decorator to create an attribute, it also creates a property with the same name, so you have a property-attribute pair.

The example above creates an attribute called label and a property called label. Any change in one automatically updates (is reflected to) the other, keeping them synchronized.

The label property is tied to the label HTML attribute. So to override the value of label, you can simply replace it in the HTML.

Usage in HTML

Now when you use <my-button> in your HTML, you can change the default value of label ('Submit'). In the example below, we are changing it to Cancel.

 <my-button label="Cancel"></my-button>

The labelChanged method in the custom element is not necessary. But it is useful if you want to perform additional actions when the label attribute changes, such as logging or triggering other side effects. The @attr decorator handles the synchronization between the attribute and property (updating the DOM whenever label changes).

info

Here are the most important things to know about attributes:

  • They can be boolean or string.
    • String attributes have string key-value pairs attribute="value": for example, "name=John"
    • Boolean attributes are considered true if their name is added to a component, and false if not.
  • They are used in HTML markup.
  • They are visible in Browser Console DOM inspector.

Attribute-property pairs

It's important to distinguish between attributes and properties. Look at the example above where we added:

@attr label = "Click Me";

This created both an attribute and a property called "label". The @attr decorator creates a pair, so changes to either the attribute or the property are reflected in the other. Once you have done this, updates to one (e.g. setting a property) can update the other (e.g. the attribute in HTML).

important

You can define properties internally in the component class without linking them to HTML attributes. These internal properties are not exposed in the DOM as attributes. This isn't the same as the private keyword which prohibits access to a property from outside the class.

clickCount is an internal property and is only accessible within the component. If it was used in the markup, then the initial value would be shown, but changes to the property after initial render are not reactive in the HTML markup.

@customElement({
name: "my-button",
template: html<MyButton>`
<button>Click Me</button>
`,
styles,
})
export class MyButton extends GenesisElement {
@attr label = "Click Me"; // Linked attribute + property for label
clickCount = 0; // Property, not linked to an attribute
}

Binding attribute values

For most attributes, you can directly bind their values to expressions in the template. This enables them to be updated dynamically, based on properties in the component.

The label attribute controls the text shown on the button. To bind label to a dynamic value, we use an expression within the template.

Here’s an example of this for a <my-button> component:

@customElement({
name: 'my-button',
template: html<MyButton>`<button>${(x) => x.label}</button>`,
styles,
})

export class MyButton extends GenesisElement {
// Here we are binding the text inside the button to the `label` attribute.
@attr label = 'Click Me';
};

Usage in HTML

When you use the component in your HTML, the label attribute binds directly to the button's text via the ${(x) => x.label} in the component definition.

  • If label is set to "Cancel" in HTML (<my-button label="Cancel">), the button displays "Cancel".
  • When the label value is changed dynamically, the button text is updated.
<my-button label="Cancel"></my-button>

Interpolated attribute values

Where an attribute value is partially static and partially dynamic, you can combine the static and dynamic parts using interpolated expressions. This approach is useful for creating customized button labels or CSS classes.

Consider this example for the MyButton component. You can use interpolation to dynamically construct an aria-label that combines a static prefix ("Perform action:") with a dynamic property (action). Similarly, the button’s visible label can be updated based on the label property:

@customElement({
name: "my-button",
template: html<MyButton>`<button
aria-label="${(x) =>
`Perform action: ${x.action}`}" //textual description for assistive technologies
label="${(x) => `Click: ${x.label}`}"
class="button"
>
${(x) => `${x.label}`}
</button>`,
styles,
})
export class MyButton extends GenesisElement {
@attr label = "Go"; // Button text label
@attr action = "Save";
}
tip

label is strictly for visibility. By contrast, aria-label provides a textual description of an element's purpose or action for assistive technologies. This ensures that users relying on screen readers can understand and interact with the element, even if its visible text or appearance is unclear or insufficient.

Binding boolean attributes

With boolean attributes, their presence or absence determines their value (the string value itself does not).

You can configure an attribute as a boolean by setting the optional mode parameter to boolean on the @attr decorator. If the attribute is a boolean and the mode is set to "boolean", this enables GenesisElement to add or remove the attribute from the element.

  • If a boolean attribute is present in the HTML markup (e.g., <button disabled></button>), it is interpreted as true.
  • If the attribute is absent (e.g., <my-button></my-button>), it is interpreted as false.

If you want the attribute to change dynamically, use the ? prefix. This adds or removes the attributes from the DOM, based on the specified condition.

Controlling the button dynamically

The disabled attribute controls whether the button is clickable. If you use ?disabled=${(x) => x.isDisabled}, you can bind it to the component’s isDisabled property and dynamically control whether the button is enabled or disabled.

@customElement({
name: "my-button",
template: html<MyButton>`
<button ?disabled="${(x) => x.isDisabled}" @click="${(x) => x.handleClick}">
${(x) => x.label}
</button>
`,
styles,
})
export class MyButton extends GenesisElement {
@attr label = "Click Me";
@attr({ mode: "boolean", attribute: "disabled" }) isDisabled = false; // Use "disabled" in HTML

handleClick() {
if (!this.isDisabled) {
console.log("Button clicked");
}
}
}

Usage in HTML

Here is a simple example of using the attribute in HTML. By default, the button is disabled, but this can be changed dynamically:

<my-button label="Submit" ?disabled="${(x) => x.setButtonDisabled}"></my-button>

In this example:

  • The ?disabled="${(x) => x.isDisabled}" syntax causes the templating engine to handle boolean attributes automatically.
    • If isDisabled is true, the disabled attribute is added to the button (<button disabled>).
    • If isDisabled is false, the disabled attribute is removed from the button.
  • The @attr({ mode: 'boolean' }) decorator ensures that changes to isDisabled in JavaScript reflect in the DOM as a disabled attribute, and vice versa.
  • The attribute key in the @attr decorator allows you to explicitly define the attribute name used in HTML. In this example:
    • isDisabled is a property and it and follows camelCase convention in JavaScript which help distinguish from the HTML attribute disabled.
    • disabled is an attribute and it's used in HTML to represent state declaratively and reflect it in the DOM.

Observable properties

In all the attributes examples, note that when the attribute is changed its updated value is shown on the template. This is because all attributes declared with the @attr decorator are also Observables. Observables are a key way to make the components reactive in the browser to their changing state.

You can also declare a property to be observable using the @observable decorator. As with attributes, observable properties enable you to create reactive components.

  • Observable properties automatically trigger updates to any part of the template or component that depends on them. Whenever an observable property changes, the UI or other bound elements are updated without additional code. This is the same as @attr.
  • Use @observable to mark a property as an observable.
  • The same as attributes, there is a built-in mechanism to execute code whenever the property’s value changes. This callback method is named after the observable property, with the suffix 'Changed'.

Below is another example of a <my-button> component.

  • The @observable decorator creates a count property that updates dynamically every time a button is clicked.
  • The countChanged callback logs each change.
import { observable, customElement, GenesisElement, html } from '@genesislcap/web-core';

@customElement({
name: 'my-button',
template: html<MyButton>`
<button @click="${(x) => x.increment()}">
${(x) => `Clicked: ${x.count} times`}
</button>`,
styles,
})
export class MyButton extends GenesisElement {
@observable count = 0; // Observable count property

// Method to increment the count
increment() {
this.count+= 1; // Increases the count by 1
}

// Callback triggered whenever `count` changes
countChanged(oldValue, newValue) {
console.log(`Count changed from ${oldValue} to ${newValue}`);
}
}

Look at that example again in more detail:

  • The @observable decorator makes count reactive. So, any change in count automatically updates the bound elements in the template.
  • The template displays the current count as part of the button text. Whenever count changes, this binding is re-rendered, showing the latest value without extra code.
  • The countChanged method is automatically called whenever count changes. This callback receives the oldValue and newValue, enabling you to track and log each change in the console.
tip

In the above simple example count could be an attribute too, but it's useful to use it as a property instead so we can work with the number directly rather than working with a numeric string. As with all properties observable properties can be any type, including arrays and objects.

Usage in HTML

We can declare the button in the HTML with a simple statement:

<my-button></my-button>

In its initial state, the button displays "Clicked: 0 times".

After there have been clicks on the button:

  • After the first click, the button displays Clicked: 1 times.
  • The count on the display is incremented with each click.

Additionally, each time the count updates, countChanged logs the old and new values to the console:

Count changed from 0 to 1
Count changed from 1 to 2

Property template binding

You could tweak the above example like this:

<my-button></my-button>
<my-button :count="${(_) => 5}"></my-button>

You now have two buttons. The first is identical to what you had before. The second button starts with a count of five and then increments from there as you press the button, because we've configured the property to have a value of 5 initially.

Unlike setting an attribute, you set a property using a function expression. In the example above, this always returns 5, but it could be a more complex function that is reactive (and would therefore update the template). You can return a type from the expression to match the type of the property, including arrays and objects.

Unlike an attribute where you set the value with no prefix, or a ? if you're setting a dynamic boolean value, you use the : prefix to set a property via the template.

tip

A property doesn't have to be observable to be set from the template with the : prefix. However, if you want the component to react with a template change when the binding expression updates, it must be @observable.

Complete example

import { attr, customElement, GenesisElement, html, observable } from '@genesislcap/web-core';

@customElement({
name: 'my-button',
template: html<MyButton>`
<label>${(x) => x.label}</label>
<button ?disabled="${x => x.isDisabled}" @click="${(x) => x.increment()}">
${(x) => `Clicked: ${x.count} times`}
</button>
`,
})
export class MyButton extends GenesisElement {
@observable count = 0;
@attr label = "Click Me";
@attr({ mode: "boolean", attribute: "disabled" }) isDisabled = false;
buttonVersion = "2.0";

// Method to increment the count
increment() {
if (this.isDisabled) return;
this.count += 1;
}

// Callback triggered whenever `count` changes
countChanged(oldValue, newValue) {
console.log(`Count changed from ${oldValue} to ${newValue}, of button version ${this.buttonVersion}`);
}
}

Considering just the attributes and properties, the component above declares the following API:

NameState TypeTemplate Reactive?InfoUsage Example (*)
countObservable propertyYesUpdates the template on change. as it's observable.
<my-button :count=${() => 5}>
labelObservable propertyYesReflective to the label attribute.
<my-button :label=${() => 'Test'}>
labelObservable attributeYesReflective to the label property.
<my-button label='Test'>
isDisabledObservable propertyYesReflective to the disabled attribute.
<my-button ?isDisabled=${() => false}>
disabledObservable attributeYesReflective to the isDisabled property.
<my-button disabled>
buttonVersionPropertyNoWill not update the template upon change if used there, but the state still updates - so you can use it change the component's logic.
<my-button :buttonVersion=${() => '3.0'}>

Key points

  • Creating an attribute using @attr creates both an attribute and a property. They share a name unless you specify a different name using the decorator parameters, as we did above on the disabled attribute.
  • You can set the value of a property via the template using the : prefix, but the component's template is only reactive to this change if it's declared as @observable. Attributes are always observable.
  • * The above API table is comprehensive, but in practice you should not set a property value via the template if it's linked to an attribute. E.g.
    • count is OK for setting the property via the template, as it's not linked to an attribute.
    • label isn't recommend to set via the template :label=${() => 'value'} as you should set it via the label attribute label='value', (or label=${x => x.labelExpression} if setting dynamically).
    • isDisabled is not recommended for setting via the template :isDisabled=${() => true} as you should set it via the attribute disabled (or ?disabled=${x => x.expression} if setting dynamically). buttonVersion is OK for setting the property via the template, as it's not linked to an attribute.
  • As shown in this example, you can also use expressions when setting attributes too if you wish.

API recommendations

The attributes and properties of a component create its API surface (as well as methods and events, but they're not considered here).

It is recommended when implementing reactivity in a custom component that you use an @attr attribute where you can (string or boolean), and then an @observable property if you need to.

Further to this, you should consider whether properties on your component's API can be changed into attributes. For example:

<my-component :property=${() = ({configOne: 'foo', configTwo: 'bar' })}>

The example above sets the property via the component's API, but it would be better if the API was simply defined with two attributes.

<my-component config-one=${() => 'foo'} config-two=${() => 'bar'}>

Attribute-property pairs have advantages over a property on its own. Benefits include:

  • being able to set the values in the browser inspector
  • better behaviour when a component is cloned using .cloneNode()

There might be times where you need to pass a more complex property through the template. However, if you end up passing a lot of state, you may find it cleaner to refactor the component to use a state management library, such as the foundation-store or Redux.