React vs Web Components #

This guide compares common UI patterns written in React (imperative, JavaScript-centric) with their WebUI equivalents (declarative, HTML-centric). Each section shows React and WebUI side by side so you can see how familiar patterns translate to a Web Components model.

Key Differences at a Glance #

ReactWebUI
Component modelJSX functions / classes with virtual DOMWeb Components with Shadow DOM or Light DOM
RenderingClient-side or Node.js SSRBuild-time compiled protocol, server-rendered HTML
Template languageJSX (JavaScript + HTML mixed)Separate HTML, CSS, and TypeScript files
State managementuseState, useReducer, context@observable properties with targeted DOM updates
StylingCSS-in-JS, CSS Modules, or externalScoped CSS via Shadow DOM or Global Light DOM via --dom and --css args
RuntimeReact runtime + ReactDOM in browserNo framework runtime for static content; thin hydration for interactive islands
InteractivityEvery component ships JavaScriptOnly interactive islands ship JavaScript

Simple Counter #

my-counter.jsx

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

my-counter.html

<p>Count: {{count}}</p>
<button @click="{increment()}">Increment</button>

my-counter.ts

import { WebUIElement, observable } from '@microsoft/webui-framework';

export class MyCounter extends WebUIElement {
  @observable count = 0;

  increment(): void {
    this.count += 1;
  }
}

MyCounter.define('my-counter');

What changed: Template and logic are separated into HTML and TypeScript files. No JSX, no useState hook, no setState call. The @observable decorator makes count reactive - when it changes, only the bound DOM nodes update.

Conditional Rendering #

user-greeting.jsx

function Greeting({ isLoggedIn, username }) {
  return (
    <div>
      {isLoggedIn ? (
        <p>Welcome back, {username}!</p>
      ) : (
        <p>Please sign in.</p>
      )}
    </div>
  );
}

user-greeting.html

<div>
  <if condition="isLoggedIn">
    <p>Welcome back, {{username}}!</p>
  </if>
  <if condition="!isLoggedIn">
    <p>Please sign in.</p>
  </if>
</div>

What changed: Conditional logic moves from JavaScript ternary expressions into declarative <if> directives. These are evaluated on the server during rendering - no JavaScript is shipped to the browser for static conditionals.

List Rendering #

todo-list.jsx

function TodoList({ items }) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>
          <span>{item.title}</span>
          <span className={`status ${item.state}`}>{item.state}</span>
        </li>
      ))}
    </ul>
  );
}

todo-list.html

<ul>
  <for each="item in items">
    <li>
      <span>{{item.title}}</span>
      <span class="status {{item.state}}">{{item.state}}</span>
    </li>
  </for>
</ul>

What changed: Array.map() with JSX becomes a declarative <for> directive. The key prop is replaced by the first attribute on the repeated element. This runs on the server and produces static HTML - no JavaScript array iteration in the browser.

Event Handling #

search-box.jsx

function SearchBox() {
  const [query, setQuery] = useState('');

  const performSearch = (searchQuery) => {
    // search logic
  };

  const handleKeyDown = (e) => {
    if (e.key === 'Enter') {
      performSearch(query);
    }
  };

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        onKeyDown={handleKeyDown}
        placeholder="Search..."
      />
      <button onClick={() => performSearch(query)}>Search</button>
    </div>
  );
}

search-box.html

<div>
  <input
    @input="{onInput(e)}"
    @keydown="{onKeyDown(e)}"
    placeholder="Search..."
  />
  <button @click="{performSearch()}">Search</button>
</div>

search-box.ts

import { WebUIElement, observable } from '@microsoft/webui-framework';

export class SearchBox extends WebUIElement {
  @observable query = '';

  onInput(e: InputEvent): void {
    this.query = (e.currentTarget as HTMLInputElement).value;
  }

  onKeyDown(e: KeyboardEvent): void {
    if (e.key === 'Enter') {
      this.performSearch();
    }
  }

  performSearch(): void {
    // search logic using this.query
  }
}

SearchBox.define('search-box');

What changed: Event handlers use @event syntax instead of onEvent props. Input value is read from e.currentTarget in the @input handler โ€” no DOM reference needed. No synthetic event system - the browser's native events are used directly.

Parent-Child Communication #

color-picker.jsx

function ColorPicker({ onColorChange }) {
  return (
    <div className="colors">
      <button onClick={() => onColorChange('red')}>Red</button>
      <button onClick={() => onColorChange('blue')}>Blue</button>
    </div>
  );
}

function App() {
  const [color, setColor] = useState('');

  return (
    <div>
      <ColorPicker onColorChange={setColor} />
      <p>Selected: {color}</p>
    </div>
  );
}

color-picker.html

<div class="colors">
  <button @click="{selectColor('red')}">Red</button>
  <button @click="{selectColor('blue')}">Blue</button>
</div>

color-picker.ts

import { WebUIElement } from '@microsoft/webui-framework';

export class ColorPicker extends WebUIElement {
  selectColor(color: string): void {
    this.$emit('color-change', { detail: { color } });
  }
}

ColorPicker.define('color-picker');

theme-app.html

<template shadowrootmode="open"
  @color-change="{onColorChange(e)}"
>
  <color-picker></color-picker>
  <p>Selected: {{currentColor}}</p>
</div>

theme-app.ts

import { WebUIElement, observable } from '@microsoft/webui-framework';

export class ThemeApp extends WebUIElement {
  @observable currentColor = '';

  onColorChange(e: CustomEvent): void {
    this.currentColor = e.detail.color;
  }
}

ThemeApp.define('theme-app');

What changed: React passes callback props down; WebUI uses native Custom Events that bubble up through the DOM. The child emits an event with this.$emit(), and the parent catches it with @event syntax on the component tag. Components are fully decoupled - the child doesn't reference the parent.

Styling #

product-card.jsx

import styled from 'styled-components';

const Card = styled.div`
  padding: 1rem;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
`;

const Title = styled.h3`
  color: #333;
  margin: 0 0 0.5rem 0;
`;

function ProductCard({ name, price }) {
  return (
    <Card>
      <Title>{name}</Title>
      <p>${price}</p>
    </Card>
  );
}

product-card.html

<div class="card">
  <h3>{{name}}</h3>
  <p>${{price}}</p>
</div>

product-card.css

.card {
  padding: 1rem;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
}

h3 {
  color: #333;
  margin: 0 0 0.5rem 0;
}

What changed: CSS-in-JS becomes a plain CSS file. Shadow DOM provides the style encapsulation that CSS-in-JS libraries simulate with generated class names. Styles cannot leak in or out of the component. No JavaScript runtime cost for styling.

Component Composition #

user-profile.jsx

function UserProfile({ user }) {
  return (
    <div className="profile">
      <img src={user.avatar} alt={user.name} />
      <h2>{user.name}</h2>
      <p>{user.bio}</p>
      {user.isAdmin && <AdminBadge />}
      <ul>
        {user.skills.map((skill) => (
          <li key={skill}>{skill}</li>
        ))}
      </ul>
    </div>
  );
}

user-profile.html

<div class="profile">
  <img src="{{user.avatar}}" alt="{{user.name}}" />
  <h2>{{user.name}}</h2>
  <p>{{user.bio}}</p>
  <if condition="user.isAdmin">
    <admin-badge></admin-badge>
  </if>
  <ul>
    <for each="skill in user.skills">
      <li>{{skill}}</li>
    </for>
  </ul>
</div>

What changed: JSX expressions (&&, .map(), template literals) become HTML directives (<if>, <for>, {{}}). The template reads like HTML with declarative annotations, not JavaScript with embedded markup.