Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

advl/rxe

Repository files navigation

rxelements

A reactive web component framework where Observable is the fundamental unit of UI. Every piece -- templates, state, routing, lifecycle, events -- derives from RxJS Observables. One abstraction carries everything.

Quick Start

bun add rxelements rxjs
import { BehaviorSubject } from "rxjs";
import { element, html } from "rxelements";
const Counter = element(({ subscriptions, onEvent }) => {
 const count = new BehaviorSubject(0);
 subscriptions.add(
 onEvent(".inc", "click").subscribe(() => count.next(count.value + 1)),
 );
 subscriptions.add(
 onEvent(".dec", "click").subscribe(() => count.next(count.value - 1)),
 );
 return html`
 <button class="dec">-</button>
 <span>${count}</span>
 <button class="inc">+</button>
 `;
});
customElements.define("my-counter", Counter);
<my-counter></my-counter>
<script type="module" src="./main.js"></script>

element() creates a custom element class. The factory function receives reactive primitives (onEvent, subscriptions) and returns a template. html is a tagged template that subscribes to Observables in each interpolation hole -- when count emits, only the <span> updates. customElements.define() registers the tag with the browser.

Prerequisites

Required: RxJS 7.8+, any modern browser (web components are supported everywhere).

Recommended: TypeScript 5+, Bun or any ESM-capable bundler.

Nice to have: Familiarity with RxJS operators (map, combineLatest, switchMap, takeUntil).


How It Works

Observable-per-hole rendering

The html tagged template parses a template once, caches the DOM skeleton, and binds each interpolation hole to its value. When a hole contains an Observable, the template engine subscribes and patches only that DOM position on each emission. No virtual DOM. No reconciliation. No wasted work.

html`
 <div class="sidebar">${nav$}</div>
 <div class="main">${content$}</div>
 <div class="footer">${status$}</div>
`;

Each of nav$, content$, status$ is an independent subscription. They update at different rates without coordination. Synchronization is opt-in via combineLatest.

Reactive state

The store is a reducer over an action stream. Mutators create typed actions. Selectors are derived Observables. Middleware transforms the action stream using RxJS operators.

mutator call → action Subject → middleware pipeline → reducer (scan) → state$ → selectors → template holes

Routing

The router is a standalone Observable. createRouter returns a route$ BehaviorSubject that emits the current MatchedRoute whenever the URL changes. There is no <router-outlet> component -- you switchMap over route$ in your template to swap views. This means routing is just another Observable composition, not a separate paradigm.

URL change → route$ (BehaviorSubject) → switchMap in template → view swap

Component lifecycle

A component is its subscriptions. Mount creates them, unmount destroys them.

  • subscriptions.add(sub) -- auto-cleanup on disconnect (90% of cases)
  • takeUntil(unmount$) -- for side effects needing completion semantics

Trade-offs

  • Observables vs signals: More powerful (async, backpressure, scheduling), more overhead per subscription. Acceptable for the target scope.
  • Runtime, not compiled: Templates are parsed at runtime, cached by call-site identity. No Svelte-style compilation.
  • No virtual DOM: Holes patch directly. Faster for independent updates, but no automatic reconciliation for structural changes beyond when()/repeat().

Progressive Examples

Five complete examples that introduce concepts incrementally. Each builds on the previous one and is copy-paste-runnable.

Example 1: Hello World

The smallest possible component. This introduces the three things every rxelements app needs: element() to create the class, html to create a reactive template, and customElements.define() to register the tag with the browser.

import { element, html } from "rxelements";
// element() takes a factory function and returns a standard HTMLElement class.
// The factory runs once per mount and returns what to render.
const Hello = element(() => {
 return html`<p>Hello, rxelements.</p>`;
});
// Register as a custom element -- the tag name must contain a hyphen.
customElements.define("hello-world", Hello);
<hello-world></hello-world>

Example 2: Reactive State and Events

Now we add interactivity. A BehaviorSubject holds state, onEvent() turns DOM events into Observables, and placing an Observable in a template hole makes it reactive -- the DOM updates automatically when the value changes.

import { BehaviorSubject, map } from "rxjs";
import { element, html, when } from "rxelements";
const Toggle = element(({ subscriptions, onEvent }) => {
 // BehaviorSubject holds current state and emits on change.
 const on$ = new BehaviorSubject(false);
 // onEvent(selector, eventName) returns an Observable<Event> using
 // event delegation on the shadow root. The selector targets elements
 // inside the template.
 subscriptions.add(
 onEvent(".toggle-btn", "click").subscribe(() => on$.next(!on$.value)),
 );
 // Derive display text from state -- standard RxJS map.
 const label$ = on$.pipe(map(v => v ? "ON" : "OFF"));
 // when() conditionally renders one branch or the other.
 // Old branch tears down, new branch mounts (switchMap semantics).
 return html`
 <button class="toggle-btn">${label$}</button>
 ${when(
 on$,
 () => html`<p>The light is on.</p>`,
 () => html`<p>The light is off.</p>`,
 )}
 `;
});
customElements.define("light-toggle", Toggle);

New concepts: BehaviorSubject as state, onEvent() for events, when() for conditional rendering, map to derive values.

Example 3: Lists with repeat()

repeat() renders a list from an Observable of arrays. Each item gets a stable identity via a key function -- items with the same key are updated in place, not recreated.

import { BehaviorSubject, map } from "rxjs";
import { element, html, repeat } from "rxelements";
import { observe as o } from "rxelements";
// observe (aliased as `o` by convention, like lodash's `_`) is a Proxy
// that turns property access on an Observable into a derived Observable.
// o(user$).name is equivalent to user$.pipe(map(u => u.name), distinctUntilChanged()).
const Grocery = element(({ subscriptions, onEvent }) => {
 const items$ = new BehaviorSubject([
 { id: 1, name: "Milk" },
 { id: 2, name: "Bread" },
 ]);
 let nextId = 3;
 // Add a new item on button click.
 subscriptions.add(
 onEvent(".add", "click").subscribe(() => {
 items$.next([...items$.value, { id: nextId++, name: `Item ${nextId - 1}` }]);
 }),
 );
 return html`
 <button class="add">Add item</button>
 <ul>
 ${repeat(
 items$, // Observable<T[]> -- the source list
 (item) => item.id, // keyFn -- stable identity per item
 (item$, index$) => html` <!-- templateFn -- receives per-item Observables -->
 <li>#${index$}: ${o(item$).name}</li>
 `,
 )}
 </ul>
 `;
});
customElements.define("grocery-list", Grocery);

New concepts: repeat() for keyed lists, observe() for reactive property access.

Example 4: Store and Persistence

For state that is shared across components or needs structure, createStore provides a reducer-based store with typed mutators and derived selectors. persistMiddleware saves to localStorage automatically.

import { map } from "rxjs";
import {
 element, html, repeat, when,
 createStore, persistMiddleware, loadPersistedState,
 observe as o,
} from "rxelements";
import { BehaviorSubject } from "rxjs";
interface Todo { id: number; text: string; done: boolean; }
interface State { todos: Todo[]; nextId: number; }
// Store definition: reducer handles state transitions,
// mutators are typed action creators, selectors derive values.
const def = {
 reducer: (state: State, action: { type: string; payload?: unknown }) => {
 switch (action.type) {
 case "ADD": return {
 ...state,
 todos: [...state.todos, { id: state.nextId, text: action.payload as string, done: false }],
 nextId: state.nextId + 1,
 };
 case "TOGGLE": return {
 ...state,
 todos: state.todos.map(t => t.id === action.payload ? { ...t, done: !t.done } : t),
 };
 case "REMOVE": return {
 ...state,
 todos: state.todos.filter(t => t.id !== action.payload),
 };
 default: return state;
 }
 },
 mutators: {
 add: (text: string) => ({ type: "ADD" as const, payload: text }),
 toggle: (id: number) => ({ type: "TOGGLE" as const, payload: id }),
 remove: (id: number) => ({ type: "REMOVE" as const, payload: id }),
 },
 selectors: {
 count: (s: State) => s.todos.length,
 doneCount: (s: State) => s.todos.filter(t => t.done).length,
 },
};
// loadPersistedState hydrates from localStorage, falling back to defaults.
// persistMiddleware saves on every state change after the first emission.
const defaults: State = { todos: [], nextId: 1 };
const store = createStore(def, {
 initialState: loadPersistedState("todos", defaults),
 middlewares: [persistMiddleware("todos")],
});
const TodoApp = element(({ subscriptions, onEvent }) => {
 const input = new BehaviorSubject("");
 subscriptions.add(
 onEvent(".add-btn", "click").subscribe(() => {
 if (input.value.trim()) {
 store.mutators.add(input.value.trim()); // Call a mutator to dispatch a typed action.
 input.next("");
 }
 }),
 );
 // Derive the todo list from store state.
 const todos$ = store.state$.pipe(map(s => s.todos));
 return html`
 <input .value=${input} @input=${(e: Event) => input.next((e.target as HTMLInputElement).value)}>
 <button class="add-btn">Add</button>
 <!-- selectors$ are derived Observables with distinctUntilChanged built in. -->
 <p>${store.selectors$.count} items, ${store.selectors$.doneCount} done</p>
 <ul>
 ${repeat(
 todos$,
 (t) => t.id,
 (item$) => html`
 <li>
 <span @click=${() => { item$.subscribe(t => store.mutators.toggle(t.id)).unsubscribe(); }}>
 ${item$.pipe(map(t => t.done ? "✓ " : "しろまる "))}${o(item$).text}
 </span>
 </li>
 `,
 )}
 </ul>
 `;
});
customElements.define("todo-app", TodoApp);

New concepts: createStore with reducer/mutators/selectors, persistMiddleware + loadPersistedState, store.selectors$ as derived Observables.

Example 5: Multi-View App with Routing

createRouter returns an Observable-based router. There is no <router-outlet> -- you switchMap over route$ to swap views, just like any other Observable composition. interceptLinks makes <a href="..."> links navigate through the router instead of triggering full page reloads.

import { map, switchMap } from "rxjs";
import {
 element, html, when,
 createRouter, interceptLinks, createStore,
 observe as o,
} from "rxelements";
// createRouter matches URLs against route definitions and emits MatchedRoute objects.
// Routes are matched in definition order -- first match wins.
// "**" is a wildcard catch-all and should always be last.
const router = createRouter({
 routes: [
 { path: "/", component: "home-view" },
 { path: "/about", component: "about-view" },
 { path: "/items/:id", component: "item-view" }, // :id is a path parameter
 { path: "**", component: "not-found" }, // catch-all
 ],
 mode: "history", // "history" (clean URLs) or "hash" (/#/path, no server config needed)
});
const appStore = createStore({
 reducer: (state: { user: string }, action: { type: string; payload?: string }) => {
 if (action.type === "SET_USER") return { ...state, user: action.payload ?? "" };
 return state;
 },
 mutators: {
 setUser: (name: string) => ({ type: "SET_USER" as const, payload: name }),
 },
}, { initialState: { user: "Guest" } });
const App = element(({ subscriptions, unmount$ }) => {
 // switchMap over route$ swaps the view on every navigation.
 // The old view's subscriptions are cleaned up automatically (switchMap semantics).
 const view$ = router.route$.pipe(
 switchMap((route) => {
 switch (route.component) {
 case "home-view": return html`
 <h1>Home</h1>
 <p>Welcome, ${o(appStore.state$).user}</p>
 <a href="/about">About</a> | <a href="/items/42">Item 42</a>
 `;
 case "about-view": return html`<h1>About</h1><a href="/">Back</a>`;
 case "item-view": return html`
 <h1>Item ${route.params.id}</h1>
 <a href="/">Back</a>
 `;
 default: return html`<h1>404</h1><a href="/">Home</a>`;
 }
 }),
 );
 // interceptLinks makes <a> clicks navigate through the router
 // instead of causing full page reloads. It ignores external links,
 // links with target attributes, and modified clicks (ctrl, meta, shift, alt).
 interceptLinks(document.body, router, unmount$);
 return html`<nav></nav><main>${view$}</main>`;
});
customElements.define("multi-app", App);

New concepts: createRouter for URL-based navigation, route$.pipe(switchMap(...)) to swap views, route.params for path parameters, interceptLinks for SPA link handling.

These five examples cover the core of rxelements. The API Reference below documents every function in detail, and the Pattern Recipes section shows solutions to common real-world problems.


API Reference

element(factory, options?)

Creates a custom element class. Does NOT register it -- call customElements.define() separately.

const MyComponent = element(factory, options?);
customElements.define("my-component", MyComponent);

Factory params:

Param Type Description
content$ Subject<TemplateResult | string> Push templates or strings to render
subscriptions Subscription Add subs for auto-cleanup on disconnect
unmount$ Observable<void> Emits on disconnect -- for finalize()
mount$ Observable<void> Emits on connect
adopted$ Observable<void> Emits when element is adopted into a new document
attrs$ Observable<ConvertedAttributes> Typed attribute values
onEvent(selector, name) (string, string) => Observable<Event> Event observable from shadow DOM
errors$ Subject<unknown> Collects errors from template holes

Return shorthand: If the factory returns an Observable<TemplateResult> (like the return value of html), it is piped into content$ automatically. If it returns a TemplateResult directly, that is pushed to content$ once. Return undefined (void) if you push to content$ manually.

// Return shorthand -- most common. The return value is piped into content$ for you.
const A = element(() => {
 return html`<p>Hello</p>`;
});
// Manual content$ -- useful when you need to push multiple templates over time,
// or when the render trigger comes from outside the factory return.
const B = element(({ content$, subscriptions }) => {
 subscriptions.add(
 interval(1000).pipe(
 map(n => html`<p>Tick ${n}</p>`),
 ).subscribe(content$),
 );
 // No return -- content$ is driven by the subscription above.
});

Options:

Option Type Default Description
styles CSSStyleSheet | CSSStyleSheet[] -- Adopted stylesheets
observedAttributes readonly string[] [] Attributes to observe
attributeConverters Record<string, (v: string | null) => T> {} Type converters per attribute
mode "open" | "closed" "open" Shadow DOM mode
sanitizer (html: string) => string -- Custom sanitizer for sanitized() tier

Edge cases:

  • connectedCallback creates a fresh Subscription container each time. If you move an element in the DOM (remove + re-add), the factory runs again with new subscriptions and the component re-renders.
  • Pushing a new template to content$ destroys the previous template's subscriptions before mounting the new one -- repeatedly pushing templates does not accumulate live subscriptions.
  • onEvent(null, "resize") listens on window instead of the shadow root. Use this for global events like resize or scroll.
  • errors$ collects errors from Observable bindings in template holes. Subscribe to it if you want centralized error handling inside the component.
  • Pushing to content$ after disconnectedCallback logs a dev warning and is a no-op -- it indicates a leaked subscription.

html tagged template

Returns Observable<TemplateResult>. The template skeleton is parsed once per call site and cached via WeakMap on the TemplateStringsArray. Cloning the cached <template> element is O(DOM size), not O(template complexity).

Seven binding types:

Type Syntax Behavior
Text ${expr} Set textContent (escaped by default)
Attribute attr=${expr} setAttribute
Boolean ?attr=${expr} Add/remove attribute
Property .prop=${expr} Set DOM property
Event handler @event=${fn} addEventListener
Event Subject @event=${subject} Emit events into Subject
Nested ${templateResult} Mount child template

Each expression can be a static value (set once) or an Observable (subscribe, patch on emission).

Edge cases:

  • Text holes accept null and undefined -- they render as empty string.
  • Attribute holes: setting null, undefined, or false removes the attribute entirely.
  • Boolean attribute holes: truthy adds the attribute with empty string value, falsy removes it.
  • Property holes: the value is set directly on the DOM element (e.g., .value=${obs$} for input elements).
  • Event holes: if you pass a Subject, events are forwarded via .next(). If you pass a function, it is used as an addEventListener callback. Both are cleaned up on template destroy.
  • Nested Observables: if a text hole receives an Observable that emits another Observable, the inner Observable is subscribed automatically (useful for when() and repeat() return values). When the outer Observable emits a new inner Observable, the previous one is unsubscribed (switch semantics) -- stale templates stop receiving updates.
  • Attribute bindings work quoted (class="${x}") or unquoted (class=${x}). Composite values (class="static ${x}") are NOT supported -- the binding is skipped with a dev warning; compute the full string in one expression instead.
  • Errors thrown by Observable bindings are routed to the component's errors$ (or logged if there is none) -- they do not crash the template.
  • The html function itself returns an Observable<TemplateResult> that emits once on subscription and stays live until unsubscribed. Unsubscribing triggers destroy() which cleans up all hole subscriptions.

when(condition,ドル thenFn, elseFn?)

Conditional rendering. switchMap between branches -- old branch tears down, new branch mounts.

when(
 isEditing$,
 () => html`<input .value=${text$}>`,
 () => html`<span>${text$}</span>`,
);

Edge cases:

  • Omitting elseFn renders an empty DocumentFragment for the false branch -- no DOM nodes, no subscriptions.
  • The condition is reduced to a boolean and deduplicated internally -- duplicate truthy (or falsy) emissions do NOT tear down and rebuild the branch. Only an actual truthy/falsy transition switches branches.

repeat(items,ドル keyFn, templateFn)

Keyed list rendering. Items with the same key are updated, not recreated. templateFn receives a per-item Observable<T> and a per-item Observable<number> (the index).

repeat(
 todos$,
 (todo) => todo.id,
 (item$, index$) => html`<li>${item$.pipe(map((t) => t.text))}</li>`,
);

Edge cases:

  • Duplicate keys log a dev warning. Items with duplicate keys may not update correctly -- always use unique keys.
  • When an item is removed from the array, its DOM nodes are removed, its item$ BehaviorSubject is completed, its subscription is unsubscribed, and its TemplateResult.destroy() is called.
  • DOM order always follows array order: new items are inserted at their index, and reordered items have their existing DOM nodes moved (not recreated).
  • Sibling content after the ${repeat(...)} hole is never disturbed -- the list lives between its own sentinel comments.
  • index$ is a BehaviorSubject<number> that updates when the item's position in the array changes.
  • If templateFn returns a template that re-emits (e.g. when()), each emission replaces that item's content in place.
  • The returned Observable<TemplateResult> emits once with a container fragment. Item additions/removals mutate this container in place.

createStore(definition, config)

Reactive store with typed mutators, derived selectors, and composable middleware.

const store = createStore(
 {
 reducer: (state, action) => { /* ... */ },
 mutators: {
 increment: () => ({ type: "INC" }),
 set: (value: number) => ({ type: "SET", payload: value }),
 },
 selectors: {
 isEven: (state) => state.count % 2 === 0,
 },
 },
 { initialState: { count: 0 } },
);
store.state$ // Observable<State> -- replays current state on subscribe
store.mutators // Bound mutator functions -- call to dispatch
store.selectors$ // { isEven: Observable<boolean> }
store.dispatch(action) // Power-user: raw action dispatch
store.error$ // Observable<StoreError>
store.destroy() // Cleanup

Edge cases:

  • state$ uses shareReplay({ bufferSize: 1, refCount: true }). It replays the latest state to new subscribers and tears down when refCount drops to zero.
  • selectors$ are derived with distinctUntilChanged. Selectors can have a custom comparator property for deep equality: selectors: { items: Object.assign((s: State) => s.items, { comparator: deepEqual }) }.
  • Calling dispatch() after destroy() logs a dev warning and is a no-op.
  • If the reducer throws, the error is caught, forwarded to error$, and the previous state is preserved.
  • initialActions is an optional Observable<Action> that is merged with the action Subject. Use it to replay actions from a log or trigger initialization sequences.

Middleware: (action,ドル state$) => action$ -- full Observable toolkit. Left-to-right composition.

// Logging
const logger = (action$, state$) =>
 action$.pipe(tap((a) => console.log(a.type)));
// Rate limiting
const rateLimited = (action$) =>
 action$.pipe(groupBy((a) => a.type), mergeMap((g) => g.pipe(throttleTime(100))));
createStore(def, { initialState, middlewares: [logger, rateLimited] });

Middleware edge cases:

  • Middlewares compose left-to-right: [a, b, c] means c(b(a(action$))). The first middleware sees raw actions, each subsequent one sees the output of the previous.
  • A middleware that filters actions (e.g., filter(a => a.type !== "SKIP")) will prevent those actions from reaching the reducer.
  • A middleware can emit additional actions by using mergeMap or concatMap to add actions to the stream.
  • state$ inside middleware is a BehaviorSubject that always has the latest state. Use withLatestFrom(state$) for state-dependent transformations.

persistMiddleware(key, config?) + loadPersistedState(key, defaults, config?)

Auto-persist store state to localStorage. Two-part API:

const initialState = loadPersistedState("my-app", { count: 0 });
const store = createStore(def, {
 initialState,
 middlewares: [persistMiddleware("my-app")],
});

Config options:

Option Type Default Description
serialize (state: T) => string JSON.stringify Custom serialization
deserialize (raw: string) => T JSON.parse Custom deserialization
storage Storage localStorage Custom storage backend (e.g., sessionStorage)

Edge cases:

  • loadPersistedState merges persisted values over defaults with spread: { ...defaults, ...parsed }. Missing keys in persisted data fall back to defaults.
  • If localStorage is unavailable (private browsing, SSR), both functions silently fall back -- loadPersistedState returns defaults, persistMiddleware skips writes.
  • The middleware saves on every state change after the initial emission (uses skip(1) internally). If storage is full, the write error is caught and ignored.
  • The key must match between loadPersistedState and persistMiddleware.

createRouter(config)

Observable-based router. Standalone -- no dependency on the element or store systems. The router emits MatchedRoute objects as the URL changes. You consume route$ like any other Observable -- typically via switchMap to swap views in a template hole.

There is no <router-outlet> or component-based routing API. Views are just template results that you select based on the matched route. This keeps routing composable with the rest of RxJS.

const router = createRouter({
 routes: [
 { path: "/", component: "home-view" },
 { path: "/users/:id", component: "user-view" }, // :id captures a path segment
 { path: "/old-page", redirect: "/new-page" }, // redirect resolves recursively
 { path: "**", component: "not-found" }, // wildcard catch-all (must be last)
 ],
 mode: "history", // or "hash"
 base: "/app", // optional base path for apps hosted under a sub-path
});

Config options:

Option Type Default Description
routes RouteConfig[] -- Route definitions, matched in order (first match wins)
mode "history" | "hash" "history" Navigation mode
base string "/" Base path prefix, stripped before matching

Mode comparison:

  • "history" uses pushState/replaceState for clean URLs (/items/42). Requires the server to serve index.html for all routes (SPA catch-all).
  • "hash" uses location.hash for URLs like /#/items/42. Works on any static file server with no configuration -- hash changes never hit the server.

Router instance:

router.route$ // Observable<MatchedRoute> -- BehaviorSubject semantics (emits current route immediately)
router.params$ // Observable<Record<string, string>> -- only emits when params actually change
router.query$ // Observable<Record<string, string>> -- only emits when query actually changes
router.navigate("/items/42") // push navigation
router.navigate("/other", { replace: true }) // replace current history entry
router.destroy() // clean up listeners

MatchedRoute shape:

Field Type Description
path string The matched pattern from route config
component string | undefined Element tag name
params Record<string, string> Parsed :param segments (URL-decoded)
query Record<string, string> Parsed query string (URL-decoded)
data unknown Arbitrary route metadata
url string The full matched URL

Edge cases:

  • Routes are matched in definition order. First match wins. Always put ** (wildcard) last.
  • params$ and query$ use distinctUntilChanged with JSON serialization for comparison.
  • In history mode, pushState does not fire popstate. The router emits manually after pushState via an internal Subject.
  • In hash mode, navigation updates location.hash and relies on the hashchange event.
  • navigate(path, { replace: true }) uses replaceState (history) or location.replace (hash) -- no new history entry.
  • If a route has redirect instead of component, matchRoute recursively resolves the redirect target. Redirect cycles are detected and abort the match with a dev warning.
  • With a base configured, navigate() automatically prefixes pushed URLs with the base in history mode; route$ always sees base-stripped paths.
  • Malformed percent-encodings in params or query values never throw -- the raw segment is returned as-is.
  • In non-browser environments (SSR/testing), the router responds to programmatic navigate() calls only.

interceptLinks(root, router, destroy$)

Intercepts clicks on internal <a href="/..."> links inside a root element and routes them through router.navigate() instead of triggering a full page reload. Without this, every <a> click in your SPA causes a full navigation.

const App = element(({ unmount$ }) => {
 // Intercept all <a> clicks inside document.body.
 // Cleans up automatically when unmount$ emits.
 interceptLinks(document.body, router, unmount$);
 // Works with Shadow DOM roots too:
 // interceptLinks(shadowRoot, router, unmount$);
 return html`
 <a href="/about">About</a> <!-- routed via router.navigate() -->
 <a href="https://example.com">External</a> <!-- ignored (not internal) -->
 <a href="/pdf" target="_blank">PDF</a> <!-- ignored (has target) -->
 `;
});

Filtering rules: ignores middle/right clicks, modifier keys (ctrl, meta, shift, alt), external URLs, and links with a target or download attribute. Links inside shadow roots are found via the composed event path, so interception works across shadow boundaries.

Guards are not a router feature -- they are plain Observable composition:

combineLatest([router.route$, authStore.state$]).pipe(
 tap(([route, auth]) => {
 if (route.path.startsWith("/admin") && !auth.isLoggedIn) {
 router.navigate("/login", { replace: true });
 }
 }),
);

Sanitization

Three-tier trust model for template holes:

Tier API Behavior
Default ${value} Escaped via textContent -- safe, no HTML parsing
Trusted ${trusted(html)} Raw HTML -- developer asserts safety
Sanitized ${sanitized(input)} Run through sanitizer function

Configuring the sanitizer -- two ways:

import DOMPurify from "dompurify";
import { element, setDefaultSanitizer } from "rxelements";
// Global default, used by every sanitized() hole:
setDefaultSanitizer((dirty) => DOMPurify.sanitize(dirty));
// Or per element (overrides the default for templates created by this component):
const MyElement = element(factory, {
 sanitizer: (dirty) => DOMPurify.sanitize(dirty),
});

The element-level sanitizer is captured when html is called inside the factory, and is inherited by when() branches and repeat() item templates created from there.

Edge cases:

  • trusted() accepts string | Observable<string>. When passed an Observable, each emission replaces the previous HTML.
  • <script> elements are stripped from trusted() and sanitized() HTML — neither tier executes scripts. Use a real <script> element in your host page if you need execution.
  • The per-element sanitizer option is inherited by templates created synchronously — directly, via when()/repeat(), or via a switchMap over a BehaviorSubject (the first render). Templates created across a later async boundary (a navigation event) cannot see the per-element option; configure a global setDefaultSanitizer() for those.
  • sanitized() with no sanitizer reachable: a static hole errors the template Observable; a value delivered through an Observable reports to the component's errors$ (or the console) and renders nothing.
  • Default text holes are always safe. Even if you pass "<script>alert(1)</script>", it renders as visible text via textContent.

observe(source$) -- property accessor

Aliased as o by convention (like lodash's _ or jQuery's $). A Proxy that turns property access into derived Observables with distinctUntilChanged.

import { observe as o } from "rxelements";
// Without observe -- manual pipe for every property:
html`<div>${user$.pipe(map(u => u.name), distinctUntilChanged())}</div>`;
// With observe -- property access does the same thing:
html`<div>${o(user$).name}</div>`; // plucks .name reactively
html`<span>${o(user$).address.city}</span>`; // deep nesting works too

Edge cases:

  • The returned Proxy delegates only the core Observable API (subscribe, pipe, forEach, lift, toPromise) to the underlying Observable. Every other property access returns another Proxy, so you can chain arbitrarily deep -- including state fields named like Observable internals (value, source, operator), which derive normally.
  • If the source emits null or undefined, property access returns an Observable that emits undefined (no error thrown).
  • Array index access (observe(items$)[0]) is NOT supported -- numeric keys go through the Proxy and produce string-keyed lookups.
  • Method calls (observe(items$).map(...)) call the Proxy, not Array.prototype.map. Use .pipe(map(...)) instead.
  • distinctUntilChanged uses reference equality (===). For object properties that are recreated on each emission, consider observe(source$.pipe(shareReplay(1))).

fromFetch(url, options?)

Observable wrapper over fetch with retry, timeout, and abort on unsubscribe.

fromFetch<User[]>("/api/users", { retry: 3, timeout: 5000 });

Options (extends RequestInit):

Option Type Default Description
retry number 0 Number of retries on failure. 4xx responses are not retried (except 408/429)
retryDelay number | (attempt: number) => number 1000 Delay between retries (ms) or backoff function
timeout number none Timeout in ms

Edge cases:

  • Unsubscribing from the returned Observable aborts the in-flight HTTP request via AbortController.
  • 204 No Content / 205 Reset Content responses emit undefined and complete instead of failing on JSON parsing.
  • Retries skip non-retryable client errors: a 4xx response fails immediately, except 408 (request timeout) and 429 (rate limited). Network errors, timeouts, and 5xx are retried.
  • The Observable completes after emitting the parsed JSON response. It does not stay open.
  • Non-2xx responses throw FetchError with .status, .statusText, and .url properties.
  • AbortError caused by unsubscribing is silently swallowed -- it does not propagate to error handlers.
  • A caller-supplied signal in the options is honored alongside the internal one: aborting it cancels the request and propagates an AbortError to the subscriber.
  • retryDelay as a function receives the attempt number (1-based) for exponential backoff: (attempt) => Math.min(1000 * 2 ** attempt, 30000).

htmlSlot tagged template

Deprecated -- prefer the html tagged template. A simpler template function that returns Observable<string> (not Observable<TemplateResult>). Useful for slot-based elements where you need a reactive HTML string rather than a live DOM fragment.

import { htmlSlot } from "rxelements";
const label$ = new BehaviorSubject("Click me");
const html$ = htmlSlot`<button>${label$}</button>`;
// Emits: "<button>Click me</button>"

Uses combineLatest internally with debounceTime(0, animationFrameScheduler) for batching. All dynamic values are coalesced before emitting the combined string.

elementSlots(tagName, factory, options)

An alternative element API that uses Shadow DOM <slot> elements with an external <template>. Instead of the html tagged template, you define slots in HTML and bind reactive streams to them.

import { elementSlots } from "rxelements";
import { of, interval } from "rxjs";
import { map } from "rxjs/operators";
elementSlots(
 "my-widget",
 ({ slots }) => {
 slots.header = of("Welcome");
 slots.counter = interval(1000).pipe(map(n => `Count: ${n}`));
 },
 {
 templateSelector: "#widget-template",
 expectedSlots: ["header", "counter"],
 },
);

Note: elementSlots calls customElements.define() internally -- unlike element(), it registers the tag for you.

Deprecated -- legacy API retained for backward compatibility; prefer element() + html. String slot content is escaped by default: configure bindingOptions.sanitizer (or sanitizeHtml: false) to render HTML.

Operators (rxelements/operators)

Operator Wraps Purpose
untilUnmount(unmount$) takeUntil Lifecycle-scoped stream
auditRender() auditTime(0, animationFrameScheduler) Batch to animation frame
withState(store) withLatestFrom(store.state$) Combine with store state
fromSelector(store, key) store.selectors$[key] Named selector stream
prop(key) map(v => v[key]) + distinctUntilChanged Type-safe property pluck

Operator edge cases:

  • auditRender() uses animationFrameScheduler. In non-browser environments (Node.js tests), this falls back to setTimeout. Use TestScheduler for deterministic testing.
  • withState(store) uses withLatestFrom -- it requires store.state$ to have emitted at least once before the source emits. Since state$ uses startWith(initialState), this is always satisfied.
  • prop(key) applies distinctUntilChanged after mapping. If the property value is an object, reference equality is used. For deep comparison, use map(v => v[key]) + distinctUntilChanged(deepEqual) manually.
  • fromSelector(store, key) is a convenience function -- it is equivalent to store.selectors$[key].

Pattern Recipes

Ten common patterns, each with a problem statement, working code, and a one-line explanation of the key operator.

Data Fetching

1. Debounced Search

Problem: Fire an API request only after the user stops typing for 300ms.

import { BehaviorSubject } from "rxjs";
import { debounceTime, distinctUntilChanged, filter, startWith, switchMap } from "rxjs/operators";
import { element, fromFetch, html, repeat } from "rxelements";
const SearchBox = element(({ subscriptions, onEvent }) => {
 const query$ = new BehaviorSubject("");
 const results$ = query$.pipe(
 debounceTime(300),
 filter(q => q.length > 1),
 distinctUntilChanged(),
 switchMap(q => fromFetch<string[]>(`/api/search?q=${encodeURIComponent(q)}`)),
 startWith([] as string[]),
 );
 return html`
 <input @input=${(e: Event) => query$.next((e.target as HTMLInputElement).value)}>
 <ul>${repeat(results$, r => r, (r$) => html`<li>${r$}</li>`)}</ul>
 `;
});

debounceTime(300) discards emissions that arrive within 300ms of each other, and switchMap cancels the previous in-flight fetch when a new query arrives.

2. Infinite Scroll

Problem: Load more items when the user scrolls near the bottom.

import { BehaviorSubject } from "rxjs";
import { auditTime, concatMap, filter, scan, takeUntil, tap } from "rxjs/operators";
import { element, fromFetch, html, repeat } from "rxelements";
import { observe as o } from "rxelements";
const InfiniteList = element(({ subscriptions, onEvent, unmount$ }) => {
 const page$ = new BehaviorSubject(1);
 const items$ = page$.pipe(
 concatMap(p => fromFetch<Item[]>(`/api/items?page=${p}`)),
 scan((all, batch) => [...all, ...batch], [] as Item[]),
 );
 subscriptions.add(
 onEvent(null, "scroll").pipe(
 auditTime(200),
 filter(() => {
 const el = document.documentElement;
 return el.scrollTop + el.clientHeight >= el.scrollHeight - 200;
 }),
 tap(() => page$.next(page$.value + 1)),
 takeUntil(unmount$),
 ).subscribe(),
 );
 return html`
 <ul>${repeat(items$, i => i.id, (i$) => html`<li>${o(i$).name}</li>`)}</ul>
 `;
});

concatMap queues page fetches so they resolve in order, and scan accumulates all pages into a single growing array.

3. WebSocket Stream

Problem: Connect to a WebSocket and render messages reactively with auto-reconnect.

import { Observable } from "rxjs";
import { retry, scan } from "rxjs/operators";
import { element, html, repeat } from "rxelements";
function wsObservable(url: string): Observable<string> {
 return new Observable<string>(subscriber => {
 const ws = new WebSocket(url);
 ws.onmessage = (e) => subscriber.next(e.data);
 ws.onerror = (e) => subscriber.error(e);
 ws.onclose = () => subscriber.complete();
 return () => ws.close();
 }).pipe(
 retry({ delay: 3000 }),
 );
}
const Feed = element(() => {
 const messages$ = wsObservable("wss://example.com/feed").pipe(
 scan((all, msg) => [...all.slice(-50), msg], [] as string[]),
 );
 return html`
 <ul>${repeat(messages$, (_, i) => i, (m$) => html`<li>${m$}</li>`)}</ul>
 `;
});

The Observable wraps the WebSocket lifecycle -- unsubscribing calls ws.close(), and retry({ delay: 3000 }) reconnects 3 seconds after any error.

4. Polling with Backoff

Problem: Poll an API endpoint, increasing the interval on consecutive errors.

import { BehaviorSubject, EMPTY, type Observable, timer } from "rxjs";
import { catchError, switchMap, tap } from "rxjs/operators";
import { fromFetch } from "rxelements";
function pollWithBackoff<T>(url: string, baseInterval = 5000): Observable<T> {
 const errorCount$ = new BehaviorSubject(0);
 return errorCount$.pipe(
 switchMap(errors => {
 const delay = Math.min(baseInterval * 2 ** errors, 60000);
 return timer(0, delay).pipe(
 switchMap(() => fromFetch<T>(url).pipe(
 tap(() => errorCount$.next(0)),
 catchError(() => {
 errorCount$.next(errors + 1);
 return EMPTY;
 }),
 )),
 );
 }),
 );
}
// Usage in a component:
const status$ = pollWithBackoff<Status>("/api/status", 3000);

switchMap restarts the timer whenever the error count changes, and timer(0, delay) emits immediately then on the interval.

State Management

5. Optimistic Update Middleware

Problem: Show the result of a mutation immediately, then roll back if the server rejects it.

import { merge, of } from "rxjs";
import { catchError, map, mergeMap } from "rxjs/operators";
import { type Action, fromFetch, type Middleware } from "rxelements";
const optimistic: Middleware<State, Action> = (action$, state$) =>
 action$.pipe(
 mergeMap(action => {
 if (action.type !== "SAVE_ITEM") return of(action);
 // Emit optimistic update immediately
 return merge(
 of({ type: "ITEM_SAVED" as const, payload: action.payload }),
 fromFetch(`/api/items/${action.payload.id}`, {
 method: "PUT",
 body: JSON.stringify(action.payload),
 }).pipe(
 map(() => ({ type: "SAVE_CONFIRMED" as const, payload: action.payload })),
 catchError(() => of({ type: "SAVE_ROLLED_BACK" as const, payload: action.payload })),
 ),
 );
 }),
 );

merge emits the optimistic action synchronously, then the server response asynchronously -- the reducer handles both action types.

6. Undo/Redo Middleware

Problem: Add undo/redo support to any store without modifying its reducer.

import { Subject, merge } from "rxjs";
import { filter, map, tap, withLatestFrom } from "rxjs/operators";
import { type Action, type Middleware } from "rxelements";
function undoMiddleware<S extends object>(): {
 middleware: Middleware<S, Action>;
 undo: () => void;
 redo: () => void;
} {
 const undoStack: S[] = [];
 const redoStack: S[] = [];
 const undo$ = new Subject<void>();
 const redo$ = new Subject<void>();
 const middleware: Middleware<S, Action> = (action$, state$) =>
 merge(
 action$.pipe(
 withLatestFrom(state$),
 tap(([, state]) => { undoStack.push(state); redoStack.length = 0; }),
 map(([action]) => action),
 ),
 undo$.pipe(
 withLatestFrom(state$),
 filter(() => undoStack.length > 0),
 map(([, current]) => {
 redoStack.push(current);
 return { type: "__RESTORE__", payload: undoStack.pop() } as Action;
 }),
 ),
 redo$.pipe(
 withLatestFrom(state$),
 filter(() => redoStack.length > 0),
 map(([, current]) => {
 undoStack.push(current);
 return { type: "__RESTORE__", payload: redoStack.pop() } as Action;
 }),
 ),
 );
 return {
 middleware,
 undo: () => undo$.next(),
 redo: () => redo$.next(),
 };
}

The middleware merges undo/redo actions into the action stream. The reducer needs a __RESTORE__ case that replaces state with action.payload.

UI Patterns

7. Form Validation

Problem: Validate multiple fields reactively and disable submit until all pass.

import { BehaviorSubject, combineLatest } from "rxjs";
import { debounceTime, filter, map, withLatestFrom } from "rxjs/operators";
import { element, html } from "rxelements";
const Form = element(({ subscriptions, onEvent }) => {
 const email$ = new BehaviorSubject("");
 const password$ = new BehaviorSubject("");
 const emailError$ = email$.pipe(
 debounceTime(300),
 map(v => !v ? "Required" : !v.includes("@") ? "Invalid email" : ""),
 );
 const passError$ = password$.pipe(
 debounceTime(300),
 map(v => v.length < 8 ? "Min 8 characters" : ""),
 );
 const valid$ = combineLatest([emailError$, passError$]).pipe(
 map(([e, p]) => !e && !p),
 );
 subscriptions.add(
 onEvent(".submit", "click").pipe(
 withLatestFrom(valid$, email$, password$),
 filter(([, valid]) => valid),
 ).subscribe(([, , email, pass]) => {
 console.log("Submit:", email, pass);
 }),
 );
 return html`
 <input type="email" @input=${(e: Event) => email$.next((e.target as HTMLInputElement).value)}>
 <span style="color:red">${emailError$}</span>
 <input type="password" @input=${(e: Event) => password$.next((e.target as HTMLInputElement).value)}>
 <span style="color:red">${passError$}</span>
 <button class="submit" ?disabled=${valid$.pipe(map(v => !v))}>Submit</button>
 `;
});

combineLatest merges the latest values from both error streams, and ?disabled=${...} uses a boolean attribute binding to toggle the attribute.

8. Drag and Drop

Problem: Implement draggable elements using mouse event streams.

import { BehaviorSubject } from "rxjs";
import { map, switchMap, takeUntil } from "rxjs/operators";
import { element, html } from "rxelements";
const Draggable = element(({ subscriptions, onEvent, unmount$ }) => {
 const pos$ = new BehaviorSubject({ x: 50, y: 50 });
 subscriptions.add(
 onEvent(".handle", "mousedown").pipe(
 switchMap((start: MouseEvent) => {
 const startX = start.clientX - pos$.value.x;
 const startY = start.clientY - pos$.value.y;
 return onEvent(null, "mousemove").pipe(
 map((move: MouseEvent) => ({
 x: move.clientX - startX,
 y: move.clientY - startY,
 })),
 takeUntil(onEvent(null, "mouseup")),
 );
 }),
 takeUntil(unmount$),
 ).subscribe(pos$),
 );
 const style$ = pos$.pipe(
 map(p => `position:absolute;left:${p.x}px;top:${p.y}px;cursor:grab`),
 );
 return html`
 <div class="handle" style=${style$}>Drag me</div>
 `;
});

switchMap turns each mousedown into a mousemove stream that completes on mouseup -- the classic RxJS drag pattern.

9. Animated Transitions

Problem: Animate elements in and out when switching between views.

import { type Observable } from "rxjs";
import { distinctUntilChanged, switchMap, tap } from "rxjs/operators";
import { type TemplateResult } from "rxelements";
function animatedSwitch(
 condition$: Observable<boolean>,
 thenFn: () => Observable<TemplateResult>,
 elseFn: () => Observable<TemplateResult>,
): Observable<TemplateResult> {
 return condition$.pipe(
 distinctUntilChanged(),
 switchMap(value => {
 const template$ = value ? thenFn() : elseFn();
 return template$.pipe(
 tap(result => {
 // Add enter animation class to all direct children
 for (const child of result.fragment.children) {
 child.classList.add("fade-in");
 }
 }),
 );
 }),
 );
}
// Usage:
const sheet = new CSSStyleSheet();
sheet.replaceSync(`
 .fade-in { animation: fadeIn 0.3s ease-in; }
 @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
`);
const App = element(() => {
 const showA$ = new BehaviorSubject(true);
 return html`
 <button @click=${() => showA$.next(!showA$.value)}>Toggle</button>
 ${animatedSwitch(
 showA$,
 () => html`<div>View A</div>`,
 () => html`<div>View B</div>`,
 )}
 `;
}, { styles: sheet });

distinctUntilChanged prevents re-triggering the animation on duplicate emissions, and switchMap ensures the old view's subscriptions are cleaned up before the new view mounts.

Routing

10. Auth Guard Composition

Problem: Protect routes based on auth state and redirect unauthenticated users.

import { combineLatest } from "rxjs";
import { filter } from "rxjs/operators";
import { createRouter, createStore } from "rxelements";
const authStore = createStore({
 reducer: (state: { token: string | null }, action: { type: string; payload?: string }) => {
 if (action.type === "LOGIN") return { token: action.payload ?? null };
 if (action.type === "LOGOUT") return { token: null };
 return state;
 },
 mutators: {
 login: (token: string) => ({ type: "LOGIN" as const, payload: token }),
 logout: () => ({ type: "LOGOUT" as const }),
 },
 selectors: {
 isLoggedIn: (s: { token: string | null }) => s.token !== null,
 },
}, { initialState: { token: null } });
const router = createRouter({
 routes: [
 { path: "/", component: "home-view" },
 { path: "/dashboard", component: "dashboard-view", data: { requiresAuth: true } },
 { path: "/login", component: "login-view" },
 { path: "**", component: "not-found" },
 ],
});
// Guard: runs as a side effect subscription
combineLatest([router.route$, authStore.selectors$.isLoggedIn]).pipe(
 filter(([route, isLoggedIn]) => !!(route.data as any)?.requiresAuth && !isLoggedIn),
).subscribe(([route]) => {
 router.navigate(`/login?redirect=${encodeURIComponent(route.url)}`, { replace: true });
});

combineLatest reacts to either navigation or auth changes. The guard is a plain subscription -- no special router API needed.


Interop

rxelements produces standard web components. They work in any framework. createStore and createRouter are standalone RxJS utilities with zero framework dependency.

React

import { useEffect, useState } from "react";
// Using an rxelements store in React
function useStore<T extends object>(store: { state$: Observable<T> }): T | null {
 const [state, setState] = useState<T | null>(null);
 useEffect(() => {
 const sub = store.state$.subscribe(setState);
 return () => sub.unsubscribe();
 }, [store]);
 return state;
}
function Dashboard() {
 const state = useStore(appStore);
 if (!state) return null;
 return <div>Count: {state.count}</div>;
}
// Using an rxelements web component in React (wrapper)
function RxElement({ tag, ...props }: { tag: string; [key: string]: unknown }) {
 const ref = React.useRef<HTMLElement>(null);
 useEffect(() => {
 if (ref.current) {
 for (const [key, value] of Object.entries(props)) {
 if (key.startsWith("on")) {
 ref.current.addEventListener(key.slice(2).toLowerCase(), value as EventListener);
 } else {
 ref.current.setAttribute(key, String(value));
 }
 }
 }
 }, [props]);
 return React.createElement(tag, { ref });
}
// <RxElement tag="my-counter" />

Angular

import { Component, OnDestroy, OnInit } from "@angular/core";
import { Subscription } from "rxjs";
// In app.module.ts: add CUSTOM_ELEMENTS_SCHEMA to schemas
// import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
@Component({
 selector: "app-dashboard",
 template: `
 <div>Count: {{ count }}</div>
 <my-counter></my-counter>
 `,
 schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class DashboardComponent implements OnInit, OnDestroy {
 count = 0;
 private sub = new Subscription();
 ngOnInit() {
 // rxelements stores work natively with Angular's async pipe too:
 // count$ = appStore.state$.pipe(map(s => s.count));
 // template: {{ count$ | async }}
 this.sub.add(
 appStore.state$.subscribe(s => { this.count = s.count; }),
 );
 }
 ngOnDestroy() {
 this.sub.unsubscribe();
 }
}

Svelte

<script>
 import { onDestroy } from "svelte";
 // rxelements stores are RxJS Observables -- Svelte auto-subscribes with $
 import { appStore } from "./stores.js";

 // Svelte's $ prefix works with any object that has a .subscribe() method
 $: count = 0;
 const sub = appStore.state$.subscribe(s => { count = s.count; });
 onDestroy(() => sub.unsubscribe());
</script>
<div>Count: {count}</div>
<!-- Web components work natively in Svelte -->
<my-counter></my-counter>

Vue

<template>
 <div>Count: {{ count }}</div>
 <!-- Web components work natively in Vue (configure compilerOptions.isCustomElement) -->
 <my-counter></my-counter>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from "vue";
import { appStore } from "./stores.js";

const count = ref(0);
let sub;

onMounted(() => {
 sub = appStore.state$.subscribe(s => { count.value = s.count; });
});

onUnmounted(() => {
 sub?.unsubscribe();
});
</script>

In vite.config.ts, tell Vue to treat custom element tags as native:

export default defineConfig({
 plugins: [vue({
 template: {
 compilerOptions: {
 isCustomElement: tag => tag.includes("-"),
 },
 },
 })],
});

Examples

Three reference apps in apps/:

Calculator (apps/calculator/)

Store with chained operations, when() for error display, observe() for reactive values, event delegation via @click + data-action.

Todo List (apps/todo/)

Full TodoMVC: add, toggle, delete, filter (all/active/completed), persistMiddleware for localStorage, trusted() for list rendering.

Dashboard (apps/dashboard/)

createRouter with 4 routes, live stats with sparkline charts (3 independent update streams), settings persistence, observe() for reactive metrics, document.createElement for dynamic view mounting.

Run any app:

cd apps/calculator && bun run dev # http://localhost:3001
cd apps/todo && bun run dev # http://localhost:3002
cd apps/dashboard && bun run dev # http://localhost:3003

Testing

Unit tests (no DOM)

Component logic is testable without a browser. The factory is a function from Observables to Observables:

test("counter increments", () => {
 const click$ = new Subject();
 const count = new BehaviorSubject(0);
 click$.pipe(scan((n) => n + 1, 0)).subscribe(count);
 click$.next();
 expect(count.value).toBe(1);
});

Store tests

const store = createStore(def, { initialState: { count: 0 } });
store.mutators.increment();
// Assert state$ emissions with firstValueFrom or subscribe:
store.state$.subscribe(state => {
 expect(state.count).toBe(1);
});

Testing with marble diagrams

import { TestScheduler } from "rxjs/testing";
const scheduler = new TestScheduler((actual, expected) => {
 expect(actual).toEqual(expected);
});
scheduler.run(({ cold, expectObservable }) => {
 const source$ = cold("a-b-c|", { a: 1, b: 2, c: 3 });
 const result$ = source$.pipe(prop("value"));
 expectObservable(result$).toBe("a-b-c|", { a: 1, b: 2, c: 3 });
});

E2E tests (Playwright)

Each app has Playwright tests in src/testing/:

cd apps/calculator && bun run test:e2e
cd apps/todo && bun run test:e2e
cd apps/dashboard && bun run test:e2e

When to use rxelements

Use it when: You think in RxJS. You want web components. You want the smallest possible abstraction over the platform.

Don't use it when: You need SSR today. You need a large ecosystem. You need the fastest possible rendering for thousands of simultaneous bindings.


Packages

The state and routing layers ship as standalone packages with no dependency on the component framework — their only runtime dependency is RxJS. Use them from any framework, or with no framework at all.

Package Install What it is
rxelements bun add rxelements rxjs The full web-component framework (re-exports the store and router for convenience)
@rxe/store bun add @rxe/store rxjs Reactive store: typed mutators, derived selectors, RxJS middleware, persistence
@rxe/router bun add @rxe/router rxjs Observable-based client-side router: matchRoute, createRouter, interceptLinks

import { createStore } from "rxelements" and import { createStore } from "@rxe/store" resolve to the same implementation — the framework package re-exports the standalone ones, so existing imports keep working.

Repository Structure

packages/store/ @rxe/store — createStore, persistMiddleware, types (RxJS only)
packages/router/ @rxe/router — createRouter, matchRoute, interceptLinks (RxJS only)
packages/rxe/ rxelements — the framework; depends on @rxe/store + @rxe/router
 src/lib/template/ html, when, repeat, bindings, parseTemplate
 src/lib/element/ element factory, common utilities
 src/lib/observe/ observe() proxy accessor
 src/lib/operators/ untilUnmount, auditRender, withState, prop
 src/lib/fetch/ fromFetch, FetchError
 src/lib/sanitization/ trusted, sanitized
apps/calculator/ Reference app -- calculator
apps/todo/ Reference app -- TodoMVC
apps/dashboard/ Reference app -- real-time monitoring dashboard

Build: TypeScript 6 + Bun. Lint: Biome 2.4. Test: Vitest + Playwright. Module: ESM. Monorepo: Lerna + Nx (Canonical pragma conventions).


FAQ / Troubleshooting

My component renders but never updates

The most common cause is passing a plain value where an Observable is expected. In rxelements, reactivity requires an Observable in the template hole:

// Will not update:
let count = 0;
html`<span>${count}</span>`;
// Will update:
const count$ = new BehaviorSubject(0);
html`<span>${count$}</span>`;

If you are using a store, make sure you are subscribing to store.state$ or store.selectors$, not reading store.state$ synchronously.

Memory leak warnings / subscriptions not cleaned up

Use subscriptions.add(sub) for all subscriptions created inside the factory. They are automatically unsubscribed in disconnectedCallback. If you create subscriptions outside the factory (e.g., in a module-level store), manage their lifecycle manually or use takeUntil(unmount$).

element(({ subscriptions, onEvent, unmount$ }) => {
 // Preferred: auto-cleanup
 subscriptions.add(
 someStream$.subscribe(handler),
 );
 // Alternative: completion semantics
 someStream$.pipe(takeUntil(unmount$)).subscribe(handler);
});

sanitized() throws "requires a sanitizer function"

You used sanitized() in a template hole but did not configure a sanitizer. Pass one via element options, or set a global default:

import DOMPurify from "dompurify";
import { element, setDefaultSanitizer } from "rxelements";
// Per element:
const MyElement = element(factory, {
 sanitizer: (dirty) => DOMPurify.sanitize(dirty),
});
// Or globally:
setDefaultSanitizer((dirty) => DOMPurify.sanitize(dirty));

Store state$ does not emit after destroy()

This is expected. destroy() completes the internal BehaviorSubject and actionSubject. Once destroyed, the store cannot accept new actions or emit new state. Create a new store instance if needed.

onEvent() does not capture events from dynamically added elements

onEvent(selector, eventName) uses event delegation on the shadow root. It calls event.target.closest(selector) on each event. This works for dynamically added elements as long as the event bubbles and the selector matches. Non-bubbling events (like focus, blur) need to be captured differently -- use @focus=${handler} directly in the template.

Template holes inside <table>, <select>, or <svg>

The HTML parser inside <template> elements has restrictions on where certain elements can appear. For example, comment nodes (used as hole markers) are invalid direct children of <table>, <tr>, or <select>. If your template binds inside these elements, wrap the binding in a valid child:

// Problematic:
html`<table>${rows$}</table>`;
// Solution: bind inside <tbody>
html`<table><tbody>${rows$}</tbody></table>`;

Component appears blank after moving it in the DOM

When an element is removed and re-added to the DOM, disconnectedCallback fires (cleaning up all subscriptions), then connectedCallback fires again (re-running the factory with fresh subscriptions). This is by design -- the component reinitializes. If you need state to survive DOM moves, store it externally (in a module-level store or a BehaviorSubject outside the factory).

How do I pass data between components?

Use a shared store. createStore is not coupled to any element -- import the same store instance into multiple component modules:

// shared-store.ts
export const appStore = createStore(def, { initialState });
// component-a.ts
import { appStore } from "./shared-store.js";
const A = element(() => html`<div>${o(appStore.state$).count}</div>`);
// component-b.ts
import { appStore } from "./shared-store.js";
const B = element(({ subscriptions, onEvent }) => {
 subscriptions.add(onEvent(".btn", "click").subscribe(() => appStore.mutators.increment()));
 return html`<button class="btn">+</button>`;
});

Can I use rxelements without TypeScript?

Yes. The library ships as ESM JavaScript with TypeScript declarations. All APIs work in plain JavaScript -- you lose type checking for mutators, selectors, and attribute converters, but everything functions identically at runtime.


License

MIT

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

Contributors

AltStyle によって変換されたページ (->オリジナル) /