State Management
Components manage state through the following key concepts: attributes, properties, and observables. Attributes are defined on the HTML markup of the element, whereas properties can be accessed via the DOM object in Javascript (or via a special property binding syntax on the markup). Observable properties and attributes configure the component to watch for changes, allowing the associated html template to update as they are updated.
Attributes
As seen in the earlier examples, in order to create an attribute you simply use the @attr
decorator.
When you use the @attr
decorator, you are creating a property-attribute pair. This creates a binding so that any change in one will update the other, keeping them in sync.
@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}`);
}
}
The label
property is tied to an HTML attribute, so to override the value of label
we can simply replace it in the HTML.
The default value of label
is 'Submit', if no value is provided in the HTML.
<my-button label="Cancel"></my-buttonn>
The labelChanged
method is only useful if you want to perform additional actions when the label attribute changes, such as logging or triggering other side effects. Since the @attr
decorator already handles the synchronization between the attribute and property (updating the DOM whenever label changes), the labelChanged
method isn’t strictly necessary.
Attributes are:
- Always strings in HTML
- Used in HTML markup
- Visible in DOM inspector
- Used for initial state
- Slower for dynamic updates
Attribute Value Binding
For most attributes, we can directly bind their values to expressions in the template, allowing them to update 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 how this might look in a <my-button>
component:
@customElement({
name: 'my-button',
template: html<MyButton>`<button>${(x) => x.label}</button>`,
styles,
})
export class MyButton extends GenesisElement {
@attr label = 'Click Me';
// Here we are binding the text inside the button to the `label` attribute.
});
Usage in HTML
<my-button label="Cancel"></my-button>
Key Points:
- Here, the label attribute binds directly to the button's text via
${(x) => x.label}
.- If label is set to "Cancel" in HTML
(<my-button label="Cancel">)
, the button will display "Cancel".- When the
label
value is changed dynamically, the button text will automatically update.
Interpolated Attribute Values
In cases where an attribute value is partially static and partially dynamic, we can combine static and dynamic parts using interpolated expressions. This approach is useful for creating customized button labels or CSS classes.
For instance, consider 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";
}
aria-label
provides a textual description of an element's purpose or action for assistive technologies, ensuring that users relying on screen readers can understand and interact with the element, even if its visible text or appearance is unclear or insufficient.
Where as label
is strictly for visibility.
Boolean Attribute Binding
Boolean attributes are special because their presence or absence determines their value, rather than their string value. In addition to the @attr
option, an attribute can also take a configuration with the mode
option.
If the attribute is a boolean and the mode
is set to "boolean" this allows 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.
We can use a ? prefix to handle boolean attributes so they’re added or removed from the DOM based on a condition.
The
disabled
attribute controls whether the button is clickable. By using?disabled=${(x) => x.isDisabled}
, we can bind it to the component’sisDisabled
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
<my-button label="Submit" disabled></my-button>
Key Points:
- The
?disabled="${(x) => x.isDisabled}"
syntax lets the templating engine handle boolean attributes automatically.
- If
isDisabled
is true, thedisabled
attribute is added to the button (<button disabled>
).- If
isDisabled
is false, thedisabled
attribute is removed from the button.- The
@attr({ mode: 'boolean' })
decorator ensures that changes toisDisabled
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 attributedisabled
.disabled
is an attribute and it's used in HTML to represent state declaratively and reflect it in the DOM.
Properties (@attr)
It's important to distinguish between attributes and properties.
- Attributes are part of the HTML markup. They provide initial configuration and are typically static values of
type
string.- Properties on the other hand are JavaScript-based, representing the internal state and can be any data type (boolean, number, object, etc).
- Attributes can be linked to properties via
reflection
, so updates to one (e.g. setting a property) can update the other (e.g. the attribute in HTML) and vice versa.
We can manage reflection
using the @attr
decorator, making attributes and properties automatically sync to reflect the latest state.
Properties and attributes are effectively two sides of the same coin. The
@attr
decorator creates a two-way binding between a JavaScript property on the component and a DOM attribute. This means that changes to the attribute in the HTML will reflect on the property in JavaScript, and changes to the property in JavaScript will update the attribute in the DOM.
Properties can be defined internally in the component class without linking them to HTML attributes. These internal properties are not exposed in the DOM as attributes.
clickCount
is an internal property and is only accessible within the component. It is not reflected in the HTML markup. Only label
is accessible as an HTML attribute.
@customElement({
name: "my-button",
template: html<MyButton>`
<button @click="${(x) => x.handleClick()}">${(x) => x.label}</button>
`,
styles,
})
export class MyButton extends GenesisElement {
@attr label = "Click Me"; // Linked attribute for label
clickCount = 0; // Internal property, not linked to an attribute
handleClick() {
this.clickCount += 1; // Update internal state
console.log(`Button clicked ${this.clickCount} times`);
}
}
- Properties can be declared using
@attr
which provides reflection between the JavaScript property and the HTML attribute. Changes to the attribute will update the property, and vice versa.
export class MyButton extends GenesisElement {
@attr label = "Click Me"; // Declared as a linked property
}
- The : symbol is used for property binding, allowing you to bind values directly to DOM properties instead of HTML attributes. This is useful when you want to bind complex data types or frequently changing properties that shouldn’t be reflected as attributes in the DOM.
See clickCount
@customElement({
name: "my-button",
template: html<MyButton>`<button
:clickCount="${(x) => x.clickCount}" // :clickCount property binding dynamically updates the clickCount property of the <button> element.
@click="${(x) => x.handleClick()}"
>
Clicked ${(x) => x.clickCount} times
</button>`,
styles,
})
export class MyButton extends GenesisElement {
clickCount = 0; // Internal property, not reflected as an attribute
handleClick() {
this.clickCount += 1; // Update internal property
console.log(`Button clicked ${this.clickCount} times`);
}
}
Observables
Observables are a powerful tool for creating reactive components. They allow properties to automatically update the associated view (html template) within a component whenever the data changes, which is essential for dynamic and interactive elements. Observables are declared with the @observable
decorator.
- 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 update without additional code.
- Use
@observable
to mark a property as an observable. This ensures that changes to the property automatically trigger any relevant updates in the component.- For each observable property, we have 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'.
Let’s look at an example where we create a <my-button>
component with an observable count
property that updates dynamically every time a button is clicked. We’ll use the @observable
decorator and a countChanged
callback to log 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}`);
}
}
- The
@observable
decorator makescount
reactive, meaning any change incount
automatically updates the bound elements in the template.- The template displays the current
count
as part of the button text. Whenevercount
changes, this binding is re-rendered, showing the latest value without extra code.- The
countChanged
method is automatically called whenevercount
changes.
- This callback receives the
oldValue
andnewValue
, allowing you to track and log each change in the console.
Usage in HTML
<my-button></my-button>
Initial State:
The button displays "Clicked: 0 times".
After Clicks:
- First click: The button displays "Clicked: 1 times".
- And so on, incrementing with each click.
- The button displays "Clicked: 0 times".
Each time count
updates, countChanged
logs the old and new values to the console:
Count changed from 0 to 1
Count changed from 1 to 2
@observable
decorator is an effective way to manage component state, allowing properties to reactively update the UI, trigger callbacks, and handle complex data in a streamlined way.