HTML UI State
A lightweight web component for managing UI state without a framework. State lives in named atoms. HTML bindings react to those atoms automatically. JavaScript gives you full control when you need it.
The system has three parts: declaring atoms, setting them, and binding them to the DOM.
Setup
Add the expression parser before any state components. It is required for bindings and derived values to work.
<wcp-expression-parser></wcp-expression-parser>The parser is a required dependency, not an optional enhancement. A binding expression like $price * $qty > $threshold references three atoms. For the system to react correctly, it needs to know all of those dependencies before any of them change. Plain evaluation using eval, new Function, or a regular expression cannot give you that. The parser builds an AST from the expression, which lets the system extract every referenced atom statically, subscribe to all of them, and recompute the result whenever any one of them updates. That is what makes derived atoms and multi-atom bindings work.
Atoms
An atom is a named, reactive value. When an atom changes, everything bound to it updates automatically.
Declaring atoms in HTML
Wrap <wcp-atom> elements inside <wcp-ui-state>. Each atom needs a name and an initial value. Names must start with $.
<wcp-ui-state> <wcp-atom type="string" name="$colorScheme" value="light" persistent ></wcp-atom> <wcp-atom type="number" name="$count" value="1"></wcp-atom> <wcp-atom type="number" name="$price" value="50"></wcp-atom> <wcp-atom name="$priceText" derive="$price + '$'"></wcp-atom></wcp-ui-state>The derive attribute computes the atom’s value from an expression. Whenever the referenced atoms change, the derived atom updates too.
The persistent attribute syncs the atom’s value with localStorage. The value is stored under a single key and restored on page load. This does not include cross-tab synchronization beyond the native storage event.
Declaring atoms in JavaScript
You can read, create, and register atoms programmatically. This is the right approach for anything more than simple use cases.
Retrieve an existing atom by name:
const uiState = document.querySelector<UiState>('wcp-ui-state')
const $count = uiState.getAtom('$count')getAtoms returns a frozen shallow copy snapshot of all atoms at the moment of the call:
const atoms = uiState.getAtoms()const $isReady = atoms['$isReady']Create a standalone atom with createAtom. It will not be used by the component unless you register it:
const $count = uiState.createAtom(0)
// Readconst value = $count.get()
// Write$count.set(20)
// Subscribe: runs immediately, then on every change$count.subscribe((value, oldValue) => { // ...})
// Listen: runs only on changes, not immediately$count.listen((value, oldValue) => { // ...})Register the atom with the component using useAtom:
uiState.useAtom('$count', $count)If you already have a state system, pass an adapter as the third argument:
uiState.useAtom('$count', myCustomAtom, { get(atom) { return atom.get() }, set(atom, value) { atom.set(value) }, listen(atom, callback) { return atom.listen(callback) },})Setting State
From HTML
Bind a DOM event to a state update using bind-event. The EVENT object refers to the native DOM event.
Syntax: bind-event="[event]:[state](expression) ...; ..."
<input type="number" bind-event="input:$count(EVENT.target.valueAsNumber)" bind-prop="value:$count"/>You can update multiple atoms on the same event by chaining them:
<button bind-event="click: $count($count + 1) $isUpdated(true)">Update</button>From JavaScript
Use atom.set() for anything beyond simple cases:
const $count = uiState.getAtom('$count')$count.set($count.get() + 1)Bindings
Bindings connect atoms to the DOM. When an atom changes, the bound element updates automatically.
bind-attr
Sets an HTML attribute to the result of the expression.
Syntax: bind-attr="[attribute]:[expression]; ..."
<input bind-attr="placeholder:$hint" />bind-prop
Sets a JavaScript DOM property directly, bypassing the HTML attribute. Use this for properties like value on inputs where the attribute and property behave differently.
Syntax: bind-prop="[property]:[expression]; ..."
<input bind-prop="value:$count" />bind-attr-toggle
Adds or removes a boolean attribute based on whether the expression is truthy. Boolean attributes work by presence, not value, so there is no way to set them to false with an attribute value. This binding handles that correctly.
Not to be confused with attributes like aria-checked that accept the string "true" or "false". Use bind-attr for those.
Syntax: bind-attr-toggle="[attribute]:[expression]; ..."
<input bind-attr-toggle="disabled:$isLoading" />bind-class-toggle
Adds or removes a class name depending on whether the expression evaluates to true.
Syntax: bind-class-toggle="[class]:[expression]; ..."
<button bind-class-toggle="active:$isActive">Click me</button>bind-class
Sets one or more class names from a state value. Use this when the class name itself is dynamic rather than fixed.
Syntax: bind-class="[expression]"
<button bind-class="$activeClassName">Click me</button>bind-style
Sets a CSS property from an expression. Intended for simple cases such as custom properties.
Syntax: bind-style="[css-property]:[expression]; ..."
<li class="list-item" bind-style="--depth:$depth"></li>bind-show
Shows or hides the element based on the expression. When false, the element gets display: none. When true, it is restored to its previous display value.
Syntax: bind-show="[expression]"
<span class="spinner" bind-show="!$isReady"></span>bind-text
Sets the text content of the element. Multiple bindings on the same element are concatenated in order.
Syntax: bind-text="[expression]"
<span bind-text="$title"></span>Expressions
Expressions are used anywhere a binding or derivation takes a value. The syntax follows JavaScript closely but is intentionally limited. The supported operations cover most real-world cases in HTML. If an expression starts to feel complex, that is a signal to move the logic into a derived atom registered from JavaScript instead.
The available operations are:
Variables and access
variableNameobject.propertyobject.array[0]object.array.lengthConditional
condition ? valueIfTrue : valueIfFalseLogical
a && ba || ba ?? bComparison
a == b a != ba === b a !== ba > b a >= ba < b a <= bArithmetic
a + ba - ba * ba / ba % bUnary
!value-value+value~valueSupported literal types
booleanstringnumber, includingInfinityandNaNnullundefined
Out of Scope
These features are not planned. This may change if a compelling case is made.
innerHTML binding
Writing raw HTML via a binding is a security risk. Use JavaScript directly.
Rendering elements from state
Conditionally rendering or repeating elements based on state is the responsibility of a template engine or framework. This component is designed to react to state changes on existing elements, not to generate markup. Adding this would push it into framework territory. JavaScript covers the use case well enough.