GuidesBest Practices

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.

ValueTruthy?Notes
trueโœ… Yes
falseโŒ No
1, 42, -1โœ… YesAny non-zero number
0โŒ No
"hello"โœ… YesAny non-empty string
""โŒ NoEmpty string
"false"โœ… Yes โš ๏ธNon-empty string - this is truthy!
"0"โœ… Yes โš ๏ธNon-empty string - this is truthy!
[] (empty array)โš ๏ธ DiffersFalsy on the server, truthy on the client - never test it directly
{} (empty object)โš ๏ธ DiffersFalsy on the server, truthy on the client - never test it directly
[].length โ†’ 0โŒ NoUse .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 #

PracticeWhy
Provide all bound keys in server stateMissing keys silently render empty
Use template expressions over shadow observablesFewer properties, no sync bugs, no extra server state
Use @attr({ mode: 'boolean' }) for true/falseFollows HTML spec, avoids string "false" trap
Check .length for empty arraysServer and client disagree on bare []; .length of 0 is falsy on both
Return route-scoped stateSmaller payloads, faster rendering
Prefer declarative bindings over imperative DOM manipulationTemplate bindings are reactive and SSR-compatible
Use --dom light only when its composition/network wins fit the appAvoids trading native CSS isolation for the wrong workload
Author an open wrapper for Shadow islands in a Light buildKeeps slots, encapsulation, and CSS-heavy components tree-local
Put native <slot> only in an effective Shadow componentNative slots do not work in Light DOM