Stellar, like many other web frameworks, ships a reactivity model that lets you declaratively define pieces of stateful UI.
Unlike other web frameworks, however, state is defined a little different than what you might have experienced when using tools like React, Svelte, or Vue. In most frameworks, state is defined in JavaScript and then passed into component markup using some type of templating syntax. In Stellar, this relationship is inversed. Reactive state is defined in HTML and then made accessible for manipulation in JavaScript.
It's called "HTML-based state."
In practice, this means a handful custom attributes called "directives" that begin with a $
can be declaratively added to elements within a Stellar component to access the reactivity model.
Reactive directives include:
Create reactive state tied to an element's text content.
$state="name"
Shorthand:
$="name"
The $state
directive allows you to define a piece of reactive state in HTML that is then made accessible within JavaScript. If you're coming from another web framework, a new concept to become familiar with is that state is automatically initialized based on the existing text content of a stateful element.
When trying to understand a reactive state declaration, the following code...
<p $state="text">Hello world!</p>
...should be interpreted in the following way:
- The paragraph element is marked as stateful via the
$state
directive - The name of the state is
text
and can be accessed in JavaScript by callingthis.text
- The initial value of the state is the string "Hello world!"
To provide a more complete example below, state defined on the span element will be accessible as a reactive property (i.e. this.count
) in the CounterButton class and will be initialized with the number zero. Accessing state will return the current state and updating the state will automatically update the elements text content.
Note
As a convenience, all reactive state is coerced to the correct data type when being accessed in JavaScript. For example, when accessing the count
state below it will be of type number
.
<counter-button>
<button @click="increment">
Clicked <span $state="count">0</span> times
</button>
</counter-button>
<script type="module">
import { Stellar } from 'stellar-element';
class CounterButton extends Stellar {
constructor() {
super();
console.log(this.count); // Logs: 0
}
// Increment count state and update span element with new count
increment = () => this.count++;
}
customElements.define('counter-button', CounterButton);
</script>
A by-product of this model is that state must always be tied to a DOM node –– state (and all reactive directives for the matter) must be explicitly defined using an HTML element.
If for whatever reason you can't or don't want to rely on auto-initialization of state based on the text content of a stateful element, you can always manually initialize the state in the constructor
method of any Stellar component.
Warning
This method of initializing state runs the risk of causing visual instability and layout issues since the initially rendered reactive HTML will not contain any content until JavaScript is parsed and executed.
<counter-button>
<button @click="increment">
<!-- Count state has no HTML-based initial value -->
Clicked <span $state="count"></span> times
</button>
</counter-button>
<script type="module">
import { Stellar } from 'stellar-element';
class CounterButton extends Stellar {
constructor() {
super();
this.count = 0; // Count is initialized here!
console.log(this.count); // Logs: 0
}
// Increment count state and update span element with new count
increment = () => this.count++;
}
customElements.define('counter-button', CounterButton);
</script>
It is recommended that you scope your state to be as small as possible. In practice this usually means using a lot of <span>
tags within your markup to denote pockets of reactivity.
<hello-message>
<button @click="hello">Click for message</button>
<p>Message: <span $state="message"></span></p>
</hello-message>
<script type="module">
import { Stellar } from 'stellar-element';
class HelloMessage extends Stellar {
hello = () => {
this.message = 'Hey there!'; // Update span element with text "Hey there!"
};
}
customElements.define('hello-message', HelloMessage);
</script>
An intentional goal of this model is to be extremely SSR-friendly and align (mostly) well with hypermedia-driven principles. While most of the other examples in our documentation demonstrate handwriting state directly inside HTML elements, the real use case and goal is to declare initial state in your server and pass the state into Stellar components using the templating language of your server framework.
When used in a framework like Astro, initial component state can start in the server, be encoded directly into your HTML, and then be seamlessly hydrated on the client without any layout shift issues or flashes of new content once JavaScript is parsed and executed. Read our Using Stellar and Astro docs for even more information about this method of building web applications.
---
// State starts in the server!
const initial = "Hello world!"
const count = 0;
---
<hydrate-server-state>
<p $state="text">{initial}</p>
<button @click="increment">Count is: <span $state="count">{count}</span>
</hydrate-server-state>
<script>
import { Stellar } from 'stellar-element';
class HydrateServerState extends Stellar {
constructor() {
super();
console.log(this.text) // Logs "Hello world!"
this.text = "Hey there!" // Update paragraph element with text "Hey there!"
console.log(this.text) // Logs "Hey there!"
}
// Increment count state and update span element with new count
increment = () => this.count++;
}
customElements.define('hydrate-server-state', HydrateServerState);
</script>
Create reactive state tied to an element property.
$state:property="name"
Shorthand:
$property="name"
Similar to the $state
directive this will create a piece of state, but instead of reactively updating the element's text content, changes to state created with the $state:property
(or just $property
) directive will update an element's property.
Here's an example:
<toggle-checkbox>
<label>
<input type="checkbox" $checked="isChecked" $disabled="isDisabled" />
Some label
</label>
<button @click="toggleChecked">Toggle checked state</button>
<button @click="toggleDisabled">Toggle disabled state</button>
</toggle-checkbox>
<script type="module">
import { Stellar } from 'stellar-element';
class ToggleCheckbox extends Stellar {
toggleChecked = () => {
// If not disabled, toggle between checked states
this.isDisabled ? null : (this.isChecked = !this.isChecked);
};
toggleDisabled = () => {
// Toggle between disabled states
this.isDisabled = !this.isDisabled;
};
}
customElements.define('toggle-checkbox', ToggleCheckbox);
</script>
Create reactive state tied to an element's inner HTML.
$state:html="name"
Shorthand:
$html="name"
Similar, to the $state
directive this will create a piece of state, but instead of reactively updating the element's text content changes to state created with the $state:html
(or just $html
) directive will update an element's inner HTML.
A classic example of needing to reactively set inner HTML content is when building a markdown editor. Using a markdown parser (such as marked) and the $html
directive makes this task trivial –– like one line of JavaScript trivial.
<markdown-editor>
<textarea @input="updateEditor" $value="input"># Hello markdown</textarea>
<div $html="output"><h1>Hello markdown</h1></div>
</markdown-editor>
<script type="module">
import { Stellar } from 'stellar-element';
import { marked } from 'marked';
class MarkdownEditor extends Stellar {
updateEditor = () => {
// Set inner HTML of the div with the parsed markdown from the textarea
this.output = marked(this.input ?? '');
};
}
customElements.define('markdown-editor', MarkdownEditor);
</script>
Bind an element's text content to some existing state.
$bind="statename"
The $bind
directive can be thought of as a way to reflect existing state (unaltered) somewhere else in your component. If you're looking to create new (alterted) state based on other state, see the $derive
directive.
The $bind
directive accepts the name of some state that has been defined elsewhere (using the $state
or $state:property
directives) and ties (or shall we say "binds") the state and the bound element together –– whenever the state changes the bound element's text content will change as well.
<bound-values>
<button @click="increment">
Clicked <span $state="count">0</span> times
</button>
<p>Bound to count state: <span $bind="count">0</span></p>
</bound-values>
<script type="module">
import { Stellar } from 'stellar-element';
class BoundValues extends Stellar {
increment = () => this.count++; // Update all span elements tied to count state
}
customElements.define('bound-values', BoundValues);
</script>
If it helps, in other web frameworks the above markup would usually look something like this:
<!-- This is fake psuedo-code! -->
<button>Clicked {count} times</button>
<p>Bound to count state: {count}</p>
You can think of this as Stellar's way of working around not using a templating syntax and comes as a consequence of encoding state declarations into HTML.
Bind an element property to some existing state.
$bind:property="statename"
Like the $bind
directive, $bind:property
is a way of reflecting existing state (unaltered) to an element's property, such as the value
property.
Unlike $bind
, however, $bind:property
takes inspiration from projects like Svelte and Vue by implementing two way data flow. This means not only will changing the state change the bound property, but changing the property will change the state.
The canonical example of this functionality is binding a text field's value property to some state. When typing into the text field the value
property is changed, but since that property is bound to the name
state it will automatically be updated as well. The effect is that as you type, both the text field and span element will render the changes.
Note
To achieve the best experience when using SSR it's good practice to explicitly define the initial value of your bound property (as seen with value="world"
in the example below). This avoids a flash of new content once JavaScript is loaded and the state is synced with the property.
<hello-world>
<input type="text" $bind:value="name" value="world" />
<p>Hello <span $state="name">world</span>!</p>
</hello-world>
<script type="module">
import { Stellar } from 'stellar-element';
class HelloWorld extends Stellar {}
customElements.define('hello-world', HelloWorld);
</script>
To be clear, this directive is syntactic sugar and the same functionality could be achieved by manually adding an @input
event directive that triggers a callback method that will sync that text field value
with the name
state.
<hello-world>
<input type="text" @input="sync" value="world" />
<p>Hello <span $state="name">world</span>!</p>
</hello-world>
<script type="module">
import { Stellar } from 'stellar-element';
class HelloWorld extends Stellar {
sync = (event) => {
this.name = event.target.value;
};
}
customElements.define('hello-world', HelloWorld);
</script>
However, like Svelte says in their tutorial doing this every time you want to sync the changes between a property and some state feels "a bit... boilerplatey." So we'll happily take the convenience of some sugar.
Derive new state based on existing state(s), tied to an element's text content.
$derive="callback(...statenames)"
The $derive
directive can be used to create new (alterted) state based on other state in a Stellar component.
Inspired by Vue computed properties, this directive let's you declare a callback method that accepts component state (defined using $state
or $state:property
) and will be executed whenever that dependent state changes.
<derived-state>
<button @click="increment">
Clicked <span $state="count">0</span> times
</button>
<p>Doubled: <span $derive="double(count)">0</span></p>
</derived-state>
<script type="module">
import { Stellar } from 'stellar-element';
class DerivedState extends Stellar {
increment = () => this.count++;
// Whenever count state is changed the double method will be executed
// with the new state passed in as a parameter
double = (count) => count * 2;
}
customElements.define('derived-state', DerivedState);
</script>
In other web frameworks the above markup would usually look something like this:
<!-- This is fake psuedo-code! -->
<button>Clicked {count} times</button>
<p>Doubled: {count * 2}</p>
Like $bind
this is another workaround for not using a templating syntax and consequence of encoding state into HTML.
If you would like to derive new state based on multiple pieces of existing state, simply add the state names as a comma separated list to the callback method (i.e. pass the state names as parameters to the method), like so:
<span $derive="callback(state1, state2, state3, ...)"></span>
Here's an example:
<slider-math>
<input type="range" $bind:value="a" value="1" min="0" max="10" />
<input type="range" $bind:value="b" value="2" min="0" max="10" />
<input type="range" $bind:value="c" value="3" min="0" max="10" />
<p>
<span $state="a">1</span> + <span $state="b">2</span> +
<span $state="c">3</span> =
<span $derive="sum(a,b,c)">6</span>
</p>
</slider-math>
<script type="module">
import { Stellar } from 'stellar-element';
class SliderMath extends Stellar {
// Whenever state a, b, or c changes the sum method will be executed
sum = (a, b, c) => a + b + c;
}
customElements.define('slider-math', SliderMath);
</script>