Components
Components are lightweight Web Components with typed props, optional state, and a render function. Internally, the component module is now split into focused submodules (types, props coercion, render helpers), with no breaking API changes.
import { bool, component, safeHtml } from '@bquery/bquery/component';
import { sanitizeHtml, trusted } from '@bquery/bquery/security';
const activeBadge = trusted(sanitizeHtml('<em>Active</em>'));
component('user-card', {
props: {
username: { type: String, required: true },
avatar: { type: String, default: '/default-avatar.png' },
active: { type: Boolean, default: false },
},
state: { clicks: 0 },
styles: `
.card { display: grid; gap: 0.5rem; padding: 1rem; }
.active { border: 1px solid #4f46e5; }
`,
connected() {
console.log('mounted');
},
updated(change) {
console.log('updated because of', change?.name ?? 'state/signal change');
},
render({ props, state }) {
return safeHtml`
<button class="card ${props.active ? 'active' : ''}" ${bool('disabled', !props.active)}>
<img src="${props.avatar}" alt="${props.username}" />
<strong>${props.username}</strong>
${props.active ? activeBadge : ''}
<span>Clicks: ${state.clicks}</span>
</button>
`;
},
});Default component library
registerDefaultComponents() registers a small, dependency-free set of native UI primitives that are ready for Storybook previews and zero-build usage.
import { defineBqueryConfig } from '@bquery/bquery/platform';
import { registerDefaultComponents } from '@bquery/bquery/component';
defineBqueryConfig({
components: { prefix: 'ui' },
});
const tags = registerDefaultComponents();
console.log(tags);
// {
// button: 'ui-button',
// card: 'ui-card',
// input: 'ui-input',
// textarea: 'ui-textarea',
// checkbox: 'ui-checkbox'
// }Available defaults:
button– pill-shaped button withvariant,size,type, anddisabledpropscard– simple container with optionaltitle,footer, andelevatedinput– labeled text input emittinginputevents with{ value }textarea– labeled textarea emittinginputevents with{ value }checkbox– labeled checkbox emittingchangeevents with{ checked }
Slots and events
The default components expose regular slots and bubble composed custom events so they work well inside forms, routers, or shadow boundaries.
<ui-card title="Profile">
<ui-input label="Name"></ui-input>
<ui-button variant="secondary">Save</ui-button>
</ui-card>const field = document.querySelector('ui-input');
field?.addEventListener('input', (event) => {
console.log((event as CustomEvent<{ value: string }>).detail.value);
});Props
Props are defined with a type and optional required/default/validator.
props: {
count: { type: Number, default: 0 },
enabled: { type: Boolean, default: true },
meta: { type: Object, default: {} },
age: {
type: Number,
default: 0,
validator: (v) => v >= 0 && v <= 150
},
}Prop validation
Add a validator function to validate prop values:
props: {
email: {
type: String,
required: true,
validator: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
},
}If validation fails, the component throws an error (caught by onError if defined).
Prop coercion
String→ raw stringNumber→Number(value)(fallback to raw string onNaN)Boolean→'true' | '' | '1'=>true,'false' | '0'=>falseObject/Array→JSON.parse(fallback to raw string)- Custom function/constructor → called or constructed
If you need to force constructor semantics for custom classes or value objects, set construct: true on the prop definition.
props: {
publishedAt: {
type: Date,
construct: true,
},
}Missing required props without a default throw an error at runtime.
State
State is a simple internal object that can be updated via setState.
const el = document.querySelector('user-card') as HTMLElement & {
setState: (key: string, value: unknown) => void;
};
el.setState('clicks', 1);If you provide an explicit state generic, state reads and writes stay strongly typed across render(), lifecycle hooks, and the returned element class.
import { component, html } from '@bquery/bquery/component';
type Props = { label: string };
type State = { count: number; ready: boolean };
component<Props, State>('typed-counter', {
props: {
label: { type: String, required: true },
},
state: {
count: 0,
ready: false,
},
connected() {
this.setState('count', this.getState('count') + 1);
},
render({ props, state }) {
return html`<div>${props.label}: ${state.count} (${state.ready})</div>`;
},
});Signals
Declare external reactive inputs via signals when a component should re-render in response to signals or computed values.
import { component, html } from '@bquery/bquery/component';
import { computed, signal } from '@bquery/bquery/reactive';
const theme = signal<'light' | 'dark'>('light');
const themeClass = computed(() => `theme-${theme.value}`);
component('theme-badge', {
props: {},
signals: { themeClass },
render({ signals }) {
return html`<span class="${signals.themeClass.value}">Theme</span>`;
},
});Only declared signals trigger re-renders, which keeps component updates predictable and avoids accidental subscriptions to unrelated reactive reads.
Shadow DOM control
Components render into an open shadow root by default, but you can opt into closed shadow roots or render directly into the host element.
component('inline-banner', {
shadow: false,
render: () => html`<p class="banner">Rendered in light DOM</p>`,
});
component('private-panel', {
shadow: 'closed',
render: () => html`<section>Closed shadow root</section>`,
});Lifecycle hooks
beforeMount()– runs before the element renders (can modify initial state)connected()– runs when the element mountsonAttributeChanged(name, oldValue, newValue)– runs for observed prop attributes plus anything listed inobserveAttributesbeforeUpdate(newProps, oldProps)– runs before re-render; returnfalseto prevent updateupdated(change?)– runs after re-render; receives attribute-change metadata for prop-driven updates andundefinedfor state/signal updatesonAdopted()– runs when the element is adopted into a differentDocumentdisconnected()– runs on teardownonError(error)– handles errors during lifecycle/render
component('my-element', {
props: { count: { type: Number, default: 0 } },
beforeMount() {
console.log('About to mount');
},
connected() {
console.log('Mounted');
},
beforeUpdate(newProps, oldProps) {
// Prevent update if count is negative, and skip no-op updates
if (newProps.count < 0) return false;
return newProps.count !== oldProps.count;
},
updated() {
console.log('Updated');
},
disconnected() {
console.log('Disconnected');
},
onError(error) {
console.error('Error:', error);
},
render({ props }) {
return html`<div>Count: ${props.count}</div>`;
},
});Moves. Moving a mounted bQuery component with moveBefore() is not a disconnect. That covers a keyed reorder in a parent's re-render in browsers that have Element.prototype.moveBefore(), and your own parent.moveBefore(el, ref), even into a different parent. beforeUnmount, disconnected and connected do not run; the component keeps its scope, effects, on() handlers and signal subscriptions; and values it got from inject() stay bound to the provider it found when it mounted, even if the move put it under a different one. Move it with appendChild() / insertBefore() (or remove and re-insert it) when the hooks should run again.
A plain DOM move — appendChild(), a drag-and-drop sortable drop, a keyed reorder in a browser without moveBefore() — disconnects and reconnects the element, so those hooks run. On reconnect a component with signals re-renders through its re-subscription; one without signals re-renders only when its last render bound on() handlers (disconnecting disposed them, the re-render gives the markup live ones). That restoring render does not run beforeUpdate or updated, and is skipped when connected() already rendered (e.g. via setState()). With renderStrategy: 'replace' it recreates the component's DOM, so internal DOM state such as a typed value does not survive the move.
Observing extra attributes
If you need lifecycle reactions for attributes that are not part of props, declare them via observeAttributes.
component('live-panel', {
observeAttributes: ['data-state'],
onAttributeChanged(name, oldValue, newValue) {
console.log(name, oldValue, newValue);
},
render: () => html`<div>Watching data-state</div>`,
});Scoped reactivity helpers
useSignal(), useComputed(), and useEffect() create component-local reactive resources that are cleaned up automatically when the element disconnects.
Call them from lifecycle hooks such as beforeMount(), connected(), onAdopted(), or onAttributeChanged() — not from render().
import { component, html, useComputed, useEffect, useSignal } from '@bquery/bquery/component';
component('scoped-counter', {
connected() {
const count = useSignal(0);
const doubled = useComputed(() => count.value * 2);
useEffect(() => {
console.log('Scoped value:', doubled.value);
});
this.setState('count', count.value);
this.setState('doubled', doubled.value);
},
state: { count: 0, doubled: 0 },
render({ state }) {
return html`<p>${state.count} → ${state.doubled}</p>`;
},
});Rendering helpers
html– template literal helper for building HTML stringssafeHtml– escapes interpolated values for safetytrusted(sanitizeHtml(...))– opt in to reusing a sanitized fragment insidesafeHtml
Rendered component output is sanitized before it is written into the Shadow DOM. That keeps custom elements aligned with bQuery's security-by-default model while still allowing standard form attributes used by the default component library.
If a component needs a few additional tags or attributes, add a sanitize option to extend the component render allowlist without changing global sanitization defaults:
component('bq-dialog', {
sanitize: {
allowAttributes: ['open'],
},
render: () => html`<div role="dialog" open>Hello</div>`,
});Only opt into attributes whose values you control or validate. In particular, style is excluded by default and bQuery does not sanitize CSS values for you, so enabling it for untrusted input can reintroduce security risks.
How re-renders update the DOM
A re-render does not replace the component's innerHTML. The new sanitized markup is parsed into an inert <template> and the live tree is morphed into it: nodes are matched by tag and data-bq-key / id, then by position (with a short lookahead, so an element inserted or removed in front of a sibling — a validation message before an <input> — leaves that sibling in place; when either of two nodes could be kept, the one holding state, such as a form control, is), and only what changed is written. Because the nodes survive:
- text typed into an
<input>or<textarea>, the focused element and the caret/selection are kept when an unrelated signal or prop changes; - scroll positions, media playback and running CSS transitions are not reset;
- nested custom elements that stay in place are not disconnected and reconnected, so their own state and lifecycles are untouched.
A keyed reorder has to move nodes. Where the browser supports the state-preserving Element.prototype.moveBefore(), the move keeps focus and media state, and bQuery components stay mounted (they define connectedMoveCallback). Elsewhere a moved node is detached and re-attached: a moved input loses focus, and a moved custom element runs its disconnected and connected hooks. A moved bQuery component that bound on() handlers re-renders on reconnect so they keep working, but state kept outside its signals, state and props is reset by its own hooks as usual. See Lifecycle hooks for what a move does and does not run.
Attributes are diffed against the markup the component rendered last time, not against the live DOM. The template owns only what it writes: an attribute added at runtime — <details open> toggled by the user, a class set by an animation — survives a re-render, an attribute the template keeps unchanged is not re-asserted (a user-collapsed <details open> stays collapsed), and a form control's value / checked / selected property is updated only when the template changes that attribute. When the template drops value, an <option> or a checkbox / radio falls back to its text / on as in freshly parsed markup.
Selectedness follows the same rule and can go stale: a <select> changes its selection only when the template changes selected on an option. A <select> that gains multiple, or a default-selected <option> that a keyed reorder moves, keeps the browser's current selection rather than what freshly parsed markup would select. Bind selected on each option, set select.value in updated(), or change the <select>'s key so it is recreated.
A nested bQuery component with shadow: false owns its light DOM: a parent re-render never patches it back to placeholder children written in the parent's template. An update whose sanitized markup is identical to the previous render skips the DOM write entirely.
const count = signal(0);
component('x-probe', {
signals: { count },
render: ({ signals }) => html`<input id="i" /><span>${signals.count.value}</span>`,
});
// Type into #i, then:
count.value = 1; // #i is the same node and keeps its valueGive repeated siblings a key with keyedList() (or an id) so a reorder moves the existing nodes instead of patching each one into its neighbour.
Components that depend on fresh nodes on every render can opt back into full replacement:
component('x-legacy', {
renderStrategy: 'replace', // innerHTML replacement, the behaviour before 1.18
render: () => html`<canvas></canvas>`,
});Manual element class creation
If you need access to the element class (e.g., to register manually or extend behavior), use defineComponent:
import { defineComponent } from '@bquery/bquery/component';
const UserCard = defineComponent('user-card', {
props: {
username: { type: String, required: true },
},
render: ({ props }) => html`<div>${props.username}</div>`,
});
customElements.define('user-card', UserCard);Emitting events
The render function receives emit for custom events:
render({ emit }) {
emit('change', { value: 1 });
return html`<div>...</div>`;
}Storybook helpers
For Storybook string renderers, use @bquery/bquery/storybook to keep stories declarative and sanitized.
import { storyHtml, when } from '@bquery/bquery/storybook';
export const ButtonStory = {
args: { disabled: false, label: 'Save' },
render: ({ disabled, label }: { disabled: boolean; label: string }) =>
storyHtml`
<ui-card>
<ui-button ?disabled=${disabled}>${label}</ui-button>
${when(disabled, '<small>Unavailable</small>', '<small>Ready</small>')}
</ui-card>
`,
};storyHtml() sanitizes interpolated markup and understands Storybook-style boolean attribute shorthand such as ?disabled=${true}.
What's new in 1.13.0 — Component framework parity
@bquery/bquery/component gained a set of additive primitives that bring it to parity with mainstream component frameworks while staying zero-dependency and sanitizer-safe. None of the existing APIs changed.
Refs and slots
import { component, html, useEffect, useRef, useSlot } from '@bquery/bquery/component';
component('focus-input', {
connected() {
const input = useRef<HTMLInputElement>();
const items = useSlot(this, 'item'); // Signal<Element[]>
useEffect(() => console.log('Items:', items.value.length));
queueMicrotask(() => input.bind(this.shadowRoot!.querySelector('input')!));
},
render: () => html`<input /><slot name="item"></slot>`,
});For one-off slot checks without a reactive subscription, hasSlot(host, name?) returns true when the named slot (or the default slot) has assigned content, and slotText(host, name?) returns the concatenated text content of the assigned nodes.
Delegated event handlers (no inline on* attributes, no eval)
import { onClick, bindDelegatedEvents } from '@bquery/bquery/component';
component('counter', {
connected() {
bindDelegatedEvents(this);
},
state: { n: 0 },
render({ state }) {
return html`<button ${onClick(() => (state.n += 1))}>+ ${state.n}</button>`;
},
});Handlers are stored in a module-level Map keyed by opaque IDs, so the sanitizer never sees inline JavaScript. Besides the generic on(event, handler), the shortcuts onClick, onInput, onChange, and onSubmit cover the common cases.
Dependency injection
import { provide, inject, injectionKey } from '@bquery/bquery/component';
const ThemeKey = injectionKey<{ dark: boolean }>('theme');
component('app-shell', {
connected() {
provide(this, ThemeKey, { dark: true });
},
render: () => html`<slot></slot>`,
});
component('themed-button', {
connected() {
const theme = inject(this, ThemeKey, { dark: false });
console.log(theme!.dark);
},
render: () => html`<button><slot></slot></button>`,
});Uses a typed CustomEvent over the composed path — no globals.
The built-in formContextKey is a strongly-typed injection key for the enclosing <bq-form> context: components that render form inputs can inject(this, formContextKey) to discover the surrounding form and auto-bind themselves (registerField, setValue, getValue).
Lifecycle additions
beforeUnmount()— symmetric tobeforeMount, runs before disconnect cleanup.errorBoundary(error, info)— returning a string renders that markup as a fallback whenrender()throws.whenIdle(fn)— scope-trackedrequestIdleCallbackwithsetTimeoutfallback.
Async data
import { useAsync } from '@bquery/bquery/component';
connected() {
const { data, error, loading, refresh } = useAsync(async (signal) =>
(await fetch('/api/me', { signal })).json()
);
}Aborts via AbortController when the component disconnects or refresh() is re-invoked.
Imperative props (non-string objects)
host.setProp('items', ['a', 'b']);
const current = host.getProp<string[]>('items');Triggers a re-render and bypasses attribute serialization for arrays, objects, and callbacks.
css tagged template + adoptable stylesheets
import { component, css, html } from '@bquery/bquery/component';
component('themed-card', {
styles: css`
:host {
display: block;
padding: 1rem;
}
h2 {
color: ${'#0066cc'};
}
`,
render: () => html`<h2><slot></slot></h2>`,
});When Constructable Stylesheets are available, the underlying CSS is shared across instances via document.adoptedStyleSheets. Otherwise the existing <style>-tag pathway is used. Interpolations are CSS-escaped.
Two lower-level helpers support custom integrations: isComponentStyles(value) is a type guard for css-produced style payloads, and applyAdoptedStyles(shadowRoot, styles) applies such a payload to a shadow root via adoptedStyleSheets, returning false when the environment lacks support (fall back to a <style> element then).
Keyed lists
import { component, html, keyedList, reconcileKeyed } from '@bquery/bquery/component';
component('todo-list', {
state: { items: [] as Array<{ id: string; text: string }> },
render({ state }) {
return html`<ul>
${keyedList(
state.items,
(it) => it.id,
(it) => `<li>${it.text}</li>`
)}
</ul>`;
},
updated() {
const items = this.getState<Array<{ id: string }>>('items');
reconcileKeyed(
this.shadowRoot!.querySelector('ul')!,
items.map((item) => item.id)
);
},
});Pitfalls and gotchas
- Reactive helpers (
useSignal,useComputed,useEffect,useAsync) must run inside lifecycle hooks such asconnected()so the component's lifecycle scope owns the disposer. - Use
setProp/getPropfor non-string props; HTML attributes are always strings. - Delegated event helpers (
on,onClick, …) sanitize event paths — prefer them over manualaddEventListenerfor shadow-DOM safety. errorBoundaryonly catches errors thrown inside lifecycle hooks and reactive callbacks — it does not catch async rejections from user-spawned promises.csstagged templates produce adoptable stylesheets — they are shared across instances unless the template depends on instance state.
Performance notes
- Re-renders morph the existing DOM instead of replacing it, so their cost scales with what changed rather than with the template size.
keyedList()keys let the morph move list items instead of rewriting them;reconcileKeyed()is only needed withrenderStrategy: 'replace'or for DOM you reorder yourself. - A render that produces the same sanitized markup as the previous one skips the DOM write.
- Defer non-critical work with
whenIdle()so first paint stays snappy. - Share styles via
cssadoptable stylesheets to avoid per-instance<style>cost.
Testing this module
@bquery/bquery/testingexposesrenderComponent(),screen,within(),fireEvent.*, anduserEventwith shadow-DOM-aware queries.- Use
errorBoundaryin tests to assert recovery paths.
Related modules
- Reactive — signals and computed values used in components.
- View — declarative bindings inside component templates.
- Storybook — author component stories with
storyText,classMap,styleMap, etc. - Testing —
renderComponent,userEvent, andexpectAccessible.
Version history
- 1.18.0 — re-renders morph the existing DOM instead of replacing
innerHTML, preserving input values, focus, scroll position and nested custom-element state; unchanged markup skips the DOM write.renderStrategy: 'replace'restores full replacement (renderStrategyoption). - 1.17.1 — component render output passes through the
bquery-sanitizerTrusted Types policy, so components render under an enforcedrequire-trusted-types-for 'script'CSP, provided the page's CSP allows thebquery-sanitizerpolicy name (atrusted-typesdirective that omits it makes policy creation fail, and the raw string then throws). See the security model. - 1.13.0 — slot helpers, refs,
useAsync/whenIdle, DI,errorBoundary,setProp/getProp, delegated event helpers,csstagged template,keyedList/reconcileKeyed.