Best Practices #
This page covers proven patterns and common pitfalls when building WebUI applications. Following these practices will help you write components that render correctly on the server, hydrate efficiently, and stay maintainable as your application grows.
SSR State Completeness #
Every binding in your template should have a corresponding key in the server state JSON. The handler resolves bindings by looking up keys. A missing text or attribute binding renders empty; a missing condition identifier is a falsy operand, so a positive branch is hidden and a negated branch is shown.
The rule: check every {{binding}}, <if condition>, and <for each> in your template and ensure the server provides the data.
<!-- Template bindings -->
<h1>{{product.name}}</h1>
<if condition="product.inStock">
<span>In Stock ({{product.quantity}} available)</span>
</if>
<for each="review in product.reviews">
<p>{{review.text}} - {{review.author}}</p>
</for>
The server state JSON must include all referenced paths:
{
"product": {
"name": "Widget Pro",
"inStock": true,
"quantity": 42,
"reviews": [
{ "text": "Great product!", "author": "Alice" },
{ "text": "Works perfectly.", "author": "Bob" }
]
}
}
Missing keys produce silent failures - the template renders without errors, but content is absent. If your initial page load is missing data, check the server state first.
Use Template Expressions, Not Shadow Observables #
A common mistake is creating @observable properties that simply mirror a condition already expressible in the template. This adds unnecessary state, introduces synchronization bugs, and requires extra server state keys.
โ Anti-pattern - shadow observable:
@observable items: Item[] = [];
@observable hasItems = false; // mirrors items.length > 0
onItemsChanged(): void {
this.hasItems = this.items.length > 0; // manual sync
}
<if condition="hasItems">...</if>
Now you need hasItems in the server state JSON, and you must keep it synchronized with items on the client.
โ Correct - use a template expression:
<if condition="items.length">...</if>
The condition evaluator handles this directly. No extra property. No synchronization. The server provides items and the expression evaluates truthiness from its length.
Supported condition expressions #
The template condition evaluator supports:
- Dot paths:
user.profile.name - Truthiness:
items.length(zero is falsy) - Negation:
!isLoading - Comparisons:
count > 0,status == 'active' - Compound:
isLoggedIn && hasPermission
Use these instead of creating derived observables.
Boolean Attributes #
Use @attr({ mode: 'boolean' }) for True/False State #
Boolean attributes follow the HTML spec: present means true, absent means false. There is no "false" value - a string "false" is truthy.
@attr({ mode: 'boolean' }) disabled = false;
@attr({ mode: 'boolean' }) checked = false;
Bind boolean attributes in templates with the ? prefix:
<button ?disabled="{{isLoading}}">Submit</button>
<input type="checkbox" ?checked="{{isSelected}}" />
The String "false" Trap #
Never use the string "false" for boolean attributes. In HTML and JavaScript, a non-empty string is truthy.
<!-- โ WRONG - "false" is a truthy string, button will be disabled -->
<button disabled="false">Submit</button>
<!-- โ
CORRECT - use ?attr binding with a boolean value -->
<button ?disabled="{{isLoading}}">Submit</button>
In your server state JSON, use actual booleans:
{
"isLoading": false,
"isSelected": true
}
Observable Truthiness in <if> Conditions #
The <if> directive evaluates conditions with JavaScript-like truthiness, with one important exception: empty collections. The server evaluator treats an empty array or object as falsy, while the compiled client condition is plain !!value, where both are truthy. Understanding these rules prevents subtle rendering bugs.
| Value | Truthy? | Notes |
|---|---|---|
true | โ Yes | |
false | โ No | |
1, 42, -1 | โ Yes | Any non-zero number |
0 | โ No | |
"hello" | โ Yes | Any non-empty string |
"" | โ No | Empty string |
"false" | โ Yes โ ๏ธ | Non-empty string - this is truthy! |
"0" | โ Yes โ ๏ธ | Non-empty string - this is truthy! |
[] (empty array) | โ ๏ธ Differs | Falsy on the server, truthy on the client - never test it directly |
{} (empty object) | โ ๏ธ Differs | Falsy on the server, truthy on the client - never test it directly |
[].length โ 0 | โ No | Use .length to check for empty arrays |
Common patterns #
<!-- Check if an array has items -->
<if condition="items.length">
<p>Showing {{items.length}} results</p>
</if>
<!-- Check a boolean flag -->
<if condition="isLoggedIn">
<user-menu></user-menu>
</if>
<!-- Negate a condition -->
<if condition="!isLoading">
<div class="content">...</div>
</if>
Always use .length to check whether an array is empty. Never write <if condition="items">: the server treats [] as falsy while the client treats it as truthy, so server-rendered output and hydration can disagree. items.length is 0 for an empty array, which is falsy on both sides.
React Patterns to Avoid #
If you're coming from React, some familiar patterns work against WebUI's declarative template model. Here are the most common ones and their WebUI equivalents.
1. Array Rebuild for Single-Property Toggle #
โ React habit - rebuild the array to toggle a property:
// Rebuilds the entire array to toggle one item
toggleItem(id: string): void {
this.items = this.items.map(item =>
item.id === id ? { ...item, selected: !item.selected } : item
);
}
โ WebUI approach - use a template condition:
toggleItem(id: string): void {
const item = this.items.find(i => i.id === id);
if (item) {
item.selected = !item.selected;
}
}
<for each="item in items">
<div ?data-selected="{{item.selected}}">{{item.name}}</div>
</for>
2. Changed-Callback Chains #
โ React habit - useEffect chains to sync derived state:
@observable items: Item[] = [];
@observable filteredItems: Item[] = [];
@observable count = 0;
// Cascading updates
onItemsChanged(): void {
this.filteredItems = this.items.filter(i => i.active);
this.count = this.filteredItems.length;
}
โ WebUI approach - let the template handle derived values:
<if condition="items.length">
<for each="item in items">
<if condition="item.active">
<div>{{item.name}}</div>
</if>
</for>
</if>
No intermediate state. The template composes conditions directly.
3. Shadow Observables #
โ React habit - derived state stored in separate variables:
@observable firstName = '';
@observable lastName = '';
@observable fullName = ''; // shadow of firstName + lastName
onNameChanged(): void {
this.fullName = `${this.firstName} ${this.lastName}`;
}
โ WebUI approach - use expressions or provide from server state:
<!-- Bind both values directly -->
<span>{{firstName}} {{lastName}}</span>
Or, if you need a single computed value on the client, compute it in an event handler and store it as an @observable:
@observable fullName = '';
private updateFullName(): void {
this.fullName = `${this.firstName} ${this.lastName}`;
}
4. Manual DOM Sync via w-ref #
โ Anti-pattern - using refs to manually sync DOM attributes:
@observable isActive = false;
onIsActiveChanged(): void {
if (this.buttonRef) {
this.buttonRef.setAttribute('aria-pressed', String(this.isActive));
this.buttonRef.classList.toggle('active', this.isActive);
}
}
โ WebUI approach - declarative attribute binding:
<button
?aria-pressed="{{isActive}}"
?data-active="{{isActive}}"
@click="{toggle()}"
>
{{label}}
</button>
:host button[data-active] {
background: #0078d4;
color: white;
}
5. Manual classList Toggle #
โ Anti-pattern - toggling classes imperatively:
@observable theme = 'light';
onThemeChanged(): void {
this.containerRef.classList.toggle('dark', this.theme === 'dark');
this.containerRef.classList.toggle('light', this.theme === 'light');
}
โ
WebUI approach - use @observable + data attributes:
@observable theme = 'light';
<template shadowrootmode="open">
<div ?data-dark="{{theme == 'dark'}}">
<slot></slot>
</div>
</template>
:host div[data-dark] {
background: #1a1a1a;
color: #f0f0f0;
}
Route-Scoped State #
Return only the state roots rendered by the active route. Do not send complete application collections to a page that binds one record.
Validated projection manifests narrow browser hydration state automatically, but they are not a reason to over-fetch server data. See Build-Time State Projection for payload mechanics and Performance for optimization guidance.
Light DOM vs Shadow DOM #
Use Light DOM when ordinary document composition, inheritance, and shared CSS
are intentional. Use Shadow DOM when the component requires native <slot>
projection, host selectors, or a real style boundary.
Do not use <slot>, :host, :host-context, or ::slotted in an effective
Light component; the compiler rejects those combinations. In a Light build,
keep one component Shadow with a sole open wrapper:
<template shadowrootmode="open">
<slot></slot>
</template>
Only open is supported. A policy wrapper such as w-render or w-hydrate
does not select a DOM mode.
See Components for the complete authoring contract and Choose Light and Shadow DOM deliberately for performance tradeoffs.
Summary #
| Practice | Why |
|---|---|
| Provide all bound keys in server state | Missing keys silently render empty |
| Use template expressions over shadow observables | Fewer properties, no sync bugs, no extra server state |
Use @attr({ mode: 'boolean' }) for true/false | Follows HTML spec, avoids string "false" trap |
Check .length for empty arrays | Server and client disagree on bare []; .length of 0 is falsy on both |
| Return route-scoped state | Smaller payloads, faster rendering |
| Prefer declarative bindings over imperative DOM manipulation | Template bindings are reactive and SSR-compatible |
Use --dom light only when its composition/network wins fit the app | Avoids trading native CSS isolation for the wrong workload |
| Author an open wrapper for Shadow islands in a Light build | Keeps slots, encapsulation, and CSS-heavy components tree-local |
Put native <slot> only in an effective Shadow component | Native slots do not work in Light DOM |