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.
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.
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).
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.
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
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
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
- 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().
Five complete examples that introduce concepts incrementally. Each builds on the previous one and is copy-paste-runnable.
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>
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.
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.
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.
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.
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:
connectedCallbackcreates a freshSubscriptioncontainer 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 onwindowinstead 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$afterdisconnectedCallbacklogs a dev warning and is a no-op -- it indicates a leaked subscription.
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
nullandundefined-- they render as empty string. - Attribute holes: setting
null,undefined, orfalseremoves 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 anaddEventListenercallback. Both are cleaned up on template destroy. - Nested Observables: if a text hole receives an
Observablethat emits anotherObservable, the inner Observable is subscribed automatically (useful forwhen()andrepeat()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
htmlfunction itself returns anObservable<TemplateResult>that emits once on subscription and stays live until unsubscribed. Unsubscribing triggersdestroy()which cleans up all hole subscriptions.
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
elseFnrenders an emptyDocumentFragmentfor 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.
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 itsTemplateResult.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 aBehaviorSubject<number>that updates when the item's position in the array changes.- If
templateFnreturns 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.
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$usesshareReplay({ bufferSize: 1, refCount: true }). It replays the latest state to new subscribers and tears down when refCount drops to zero.selectors$are derived withdistinctUntilChanged. Selectors can have a customcomparatorproperty for deep equality:selectors: { items: Object.assign((s: State) => s.items, { comparator: deepEqual }) }.- Calling
dispatch()afterdestroy()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. initialActionsis an optionalObservable<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]meansc(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
mergeMaporconcatMapto add actions to the stream. state$inside middleware is aBehaviorSubjectthat always has the latest state. UsewithLatestFrom(state$)for state-dependent transformations.
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:
loadPersistedStatemerges 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 --
loadPersistedStatereturns defaults,persistMiddlewareskips 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
keymust match betweenloadPersistedStateandpersistMiddleware.
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"usespushState/replaceStatefor clean URLs (/items/42). Requires the server to serveindex.htmlfor all routes (SPA catch-all)."hash"useslocation.hashfor 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$andquery$usedistinctUntilChangedwith JSON serialization for comparison.- In
historymode,pushStatedoes not firepopstate. The router emits manually afterpushStatevia an internalSubject. - In
hashmode, navigation updateslocation.hashand relies on thehashchangeevent. navigate(path, { replace: true })usesreplaceState(history) orlocation.replace(hash) -- no new history entry.- If a route has
redirectinstead ofcomponent,matchRouterecursively resolves the redirect target. Redirect cycles are detected and abort the match with a dev warning. - With a
baseconfigured,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.
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 }); } }), );
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()acceptsstring | Observable<string>. When passed an Observable, each emission replaces the previous HTML.<script>elements are stripped fromtrusted()andsanitized()HTML — neither tier executes scripts. Use a real<script>element in your host page if you need execution.- The per-element
sanitizeroption is inherited by templates created synchronously — directly, viawhen()/repeat(), or via aswitchMapover aBehaviorSubject(the first render). Templates created across a later async boundary (a navigation event) cannot see the per-element option; configure a globalsetDefaultSanitizer()for those. sanitized()with no sanitizer reachable: a static hole errors the template Observable; a value delivered through an Observable reports to the component'serrors$(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 viatextContent.
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
nullorundefined, property access returns an Observable that emitsundefined(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, notArray.prototype.map. Use.pipe(map(...))instead. distinctUntilChangeduses reference equality (===). For object properties that are recreated on each emission, considerobserve(source$.pipe(shareReplay(1))).
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 Contentresponses emitundefinedand 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
FetchErrorwith.status,.statusText, and.urlproperties. AbortErrorcaused by unsubscribing is silently swallowed -- it does not propagate to error handlers.- A caller-supplied
signalin the options is honored alongside the internal one: aborting it cancels the request and propagates anAbortErrorto the subscriber. retryDelayas a function receives the attempt number (1-based) for exponential backoff:(attempt) => Math.min(1000 * 2 ** attempt, 30000).
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.
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.
| 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()usesanimationFrameScheduler. In non-browser environments (Node.js tests), this falls back tosetTimeout. UseTestSchedulerfor deterministic testing.withState(store)useswithLatestFrom-- it requiresstore.state$to have emitted at least once before the source emits. Sincestate$usesstartWith(initialState), this is always satisfied.prop(key)appliesdistinctUntilChangedafter mapping. If the property value is an object, reference equality is used. For deep comparison, usemap(v => v[key])+distinctUntilChanged(deepEqual)manually.fromSelector(store, key)is a convenience function -- it is equivalent tostore.selectors$[key].
Ten common patterns, each with a problem statement, working code, and a one-line explanation of the key operator.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
rxelements produces standard web components. They work in any framework. createStore and createRouter are standalone RxJS utilities with zero framework dependency.
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" />
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(); } }
<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>
<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("-"), }, }, })], });
Three reference apps in apps/:
Store with chained operations, when() for error display, observe() for reactive values, event delegation via @click + data-action.
Full TodoMVC: add, toggle, delete, filter (all/active/completed), persistMiddleware for localStorage, trusted() for list rendering.
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
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); });
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); });
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 }); });
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
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.
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.
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).
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.
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); });
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));
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(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.
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>`;
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).
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>`; });
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.