Skip to content

React vs Web Components

This guide compares common UI patterns written in React (imperative, JavaScript-centric) with their WebUI and FAST 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
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
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

React
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>
  );
}
WebUI

my-counter.html

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

my-counter.ts

typescript
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

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

user-greeting.html

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

React
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>
  );
}
WebUI

todo-list.html

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

React
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>
  );
}
WebUI

search-box.html

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

search-box.ts

typescript
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

React
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>
  );
}
WebUI

color-picker.html

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

color-picker.ts

typescript
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

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

theme-app.ts

typescript
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

React
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>
  );
}
WebUI

product-card.html

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

product-card.css

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

React
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>
  );
}
WebUI

user-profile.html

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.

FAST Alternative

WebUI supports two hydration plugins. The examples above use @microsoft/webui-framework. If your team uses the FAST ecosystem, the --plugin=fast option provides an alternative:

bash
webui build ./src --out ./dist --plugin=fast

The template syntax is identical - <if>, <for>, {{}}, and @click work the same way in both plugins. The difference is in the TypeScript component class:

WebUI Framework
typescript
import { WebUIElement, attr, observable } from '@microsoft/webui-framework';

export class MyCounter extends WebUIElement {
  @attr label = 'Count';
  @observable count = 0;

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

MyCounter.define('my-counter');
FAST
typescript
import { FASTElement, attr, observable } from '@microsoft/fast-element';
import { RenderableFASTElement } from '@microsoft/fast-html';

export class MyCounter extends RenderableFASTElement(FASTElement) {
  @attr label = 'Count';
  @observable count = 0;

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

  prepare(): void {
    // Manually read state from pre-rendered DOM
    this.count = Number(this.shadowRoot?.querySelector('span')?.textContent ?? 0);
  }
}

MyCounter.define({ name: 'my-counter', template: /* ... */ });
WebUI FrameworkFAST
State seedingAutomatic from SSR markersManual in prepare()
Update modelTargeted path-indexedFull observable chain
Package@microsoft/webui-framework@microsoft/fast-html + @microsoft/fast-element
Best forSSR-first apps, minimal JSComplex client interactivity, existing FAST projects

Architecture Comparison

React: client-side rendering pipeline

Build → JS Bundle → Browser downloads → Parse JS → Execute → Fetch data → Render

Every component ships JavaScript. The page is blank until the bundle loads, parses, and executes.

WebUI: server-rendered with interactive islands

Build → Protocol Binary → Server renders HTML → Browser displays immediately
                                               → JS loads only for interactive islands

Static content is visible instantly. Only components that need interactivity ship JavaScript.

MetricReact SPAWebUI Islands
First paintAfter JS bundle loadsImmediate (server HTML)
JavaScript shippedAll componentsOnly interactive islands
Server runtimeNode.js with V8Rust binary, no JS runtime
Component encapsulationConvention (CSS Modules, etc.)Native (Shadow DOM)

Summary

Moving from React to WebUI means:

  1. Separate files instead of JSX - HTML, CSS, and TypeScript each have their own file
  2. Declarative directives instead of JavaScript expressions - <if>, <for>, {{}}
  3. Native Web Components instead of framework components - Shadow DOM, Custom Elements
  4. Server-first rendering instead of client-first - content is visible before any JavaScript loads
  5. Targeted updates instead of virtual DOM diffing - only the specific DOM nodes bound to a changed property update
  6. Custom Events instead of callback props - components communicate through the standard DOM event system

Released under the MIT License