A deep-dive into the modular, zero-build frontend framework that bridges the gap between vanilla JavaScript and full-blown frameworks
Introduction
Remember jQuery? That legendary library that made DOM manipulation actually enjoyable back in the day? Well, times have changed, browsers became smarter, the web platform grew up, and build toolchains ballooned into something that requires a PhD to configure properly.
But here's the thing: sometimes you just want to grab an element, wire up some reactive state, and get on with your life. No Vite config, no node_modules rabbit hole, no framework-specific mental model to internalize. Just... JavaScript. On the web. Like the good ol' days, but modern.
That's exactly where bQuery.js comes in.
bQuery (v1.7.0 as of this writing) describes itself as "the jQuery for the modern web platform" and it earns that title. It takes the directness and ergonomics of jQuery and layers on signals-based reactivity, async data composables, native Web Components, motion, forms, i18n, accessibility primitives, drag-and-drop, SSR, and a whole lot more. All of it modular. All of it progressively adoptable.
Let's break it down.
Table of Contents
- What Is bQuery?
- Getting Started Zero Build, No Excuses
- The Core API Good Old DOM Manipulation
- Reactive Primitives Signals All the Way Down
- Async Data & Fetching
- Building Web Components with bQuery
-
@bquery/uiThe Default Component Library - The Broader Ecosystem at a Glance
- When Should You Reach for bQuery?
- Conclusion
1. What Is bQuery?
bQuery is a modular JavaScript/TypeScript library published under @bquery/bquery on npm. Its philosophy can be summed up in three bullet points:
- Zero build required works via CDN or ES modules straight in the browser; Vite is optional
- Secure by default sanitized DOM operations and Trusted Types compatibility out of the box
- Progressive import only what you need, add complexity only where you need it
The package is split into focused submodules so you never pay for what you don't use:
| Module | What it does |
|---|---|
core |
Selectors, DOM manipulation, events, utilities |
reactive |
Signals, computed, effects, async composables |
component |
Typed Web Components with Shadow DOM control |
motion |
Transitions, FLIP, springs, parallax, typewriter |
security |
Sanitization, Trusted Types, CSP helpers |
platform |
Storage, cookies, cache, page meta, announcer |
router |
SPA routing with guards and declarative links |
store |
Signal-based global state with persistence |
forms |
Reactive form state and validators |
i18n |
Locale, translations, pluralization, Intl formatting |
a11y |
Focus traps, skip links, live regions, media audits |
dnd |
Draggable, drop zones, sortable lists |
media |
Viewport, network, battery, clipboard wrappers |
plugin |
Custom directive and component registration |
devtools |
Signal/store/component inspection at runtime |
testing |
Component mounts, mock signals, async assertions |
ssr |
Server-side rendering with hydration |
That's a lot of ground covered and yet the entry point stays clean because you only import what you actually touch.
2. Getting Started Zero Build, No Excuses
The fastest way to try bQuery is dropping a <script type="module"> into an HTML file:
<!DOCTYPE html>
<html>
<head>
<title>bQuery Demo</title>
</head>
<body>
<button id="counter">Count: 0</button>
<script type="module">
import { $, signal, effect } from 'https://unpkg.com/@bquery/bquery@1/dist/full.es.mjs';
const count = signal(0);
effect(() => {
$('#counter').text(`Count: ${count.value}`);
});
$('#counter').on('click', () => {
count.value++;
});
</script>
</body>
</html>
No build step. No config. Reactive state, DOM manipulation, and event handling in ~10 lines. If that doesn't put a smile on your face, I don't know what will.
For project-based setups you install it the usual way:
npm install @bquery/bquery
# or
pnpm add @bquery/bquery
# or
bun add @bquery/bquery
And then import from the main entry point or directly from individual submodules:
// Everything from one place
import { $, signal, effect, component } from '@bquery/bquery';
// Or surgically pick submodules
import { $, $$ } from '@bquery/bquery/core';
import { signal, computed, effect, useFetch } from '@bquery/bquery/reactive';
import { component, html, registerDefaultComponents } from '@bquery/bquery/component';
3. The Core API Good Old DOM Manipulation
The core module is the jQuery-familiar part of bQuery. You get $ for single elements and $$ for collections. Both return wrapper objects with a chainable API.
import { $, $$ } from '@bquery/bquery/core';
// Single element throws if not found
$('#app')
.addClass('loaded')
.css({ color: 'rebeccapurple', fontSize: '1.2rem' })
.text('Hello, bQuery!');
// Multiple elements
$$('.card').each((el) => {
el.toggleClass('visible');
});
The single-element wrapper (BQueryElement) covers:
-
Class/attribute helpers:
addClass,removeClass,toggleClass,attr,removeAttr,data,prop -
Content:
text,html(sanitized by default!),htmlUnsafe,append,prepend,before,after -
Visibility:
show,hide,toggle,css -
Events:
on,once,off,trigger,delegate -
Traversal:
find,closest,parent,children,siblings,next,prev -
DOM manipulation:
wrap,unwrap,replaceWith,detach,scrollTo -
Form helpers:
serialize,serializeString,val -
Dimensions:
rect,offset,outerWidth,outerHeight,position
Notice that html() is sanitized by default. That's the "secure by default" principle in practice you have to explicitly call htmlUnsafe() to bypass it. A small thing that prevents a whole class of XSS bugs.
The core module also ships a solid utility belt:
import { debounce, throttle, merge, uid, utils } from '@bquery/bquery/core';
const save = debounce(() => console.log('saved!'), 300);
save.cancel(); // cancelable
const id = uid('component'); // "component-xyz123"
const merged = merge({ a: 1 }, { b: 2 }); // { a: 1, b: 2 }
Utilities include clone, pick, omit, slugify, truncate, chunk, flatten, compact, unique, randomInt, clamp, and a full suite of type guards (isString, isElement, isPromise, etc.). It's the kind of utility layer that means you actually don't need lodash.
4. Reactive Primitives Signals All the Way Down
This is where bQuery steps firmly into modern territory. The reactive module gives you fine-grained reactivity through signals the same primitive that's now baked into Angular, Solid, and Preact signals.
import { signal, computed, effect, batch, watch } from '@bquery/bquery/reactive';
const firstName = signal('John');
const lastName = signal('Doe');
// Computed values are lazy and cached
const fullName = computed(() => `${firstName.value} ${lastName.value}`);
// Effects run immediately, re-run on dependency change
effect(() => {
document.title = fullName.value;
});
// Batch multiple updates into a single notification pass
batch(() => {
firstName.value = 'Jane';
lastName.value = 'Smith';
});
// Watch with old/new value comparison
const stop = watch(firstName, (newVal, oldVal) => {
console.log(`Changed: ${oldVal} รขโ โ ${newVal}`);
});
stop(); // unsubscribe
A few things worth highlighting:
signal.peek() reads the value without creating a reactive dependency. Useful when you need to read inside an effect without it re-subscribing.
signal.update(fn) updates based on the current value handy for immutable patterns.
signal.dispose() removes all subscribers and prevents memory leaks. Important for long-lived apps.
readonly(signal) creates a read-only view. Great for exposing reactive state from a store without allowing external mutation.
untrack(() => ...) reads signals inside an effect without tracking them as dependencies.
persistedSignal syncs a signal to localStorage automatically, with graceful fallbacks for SSR and Safari private mode:
import { persistedSignal } from '@bquery/bquery/reactive';
const theme = persistedSignal('theme', 'light');
theme.value = 'dark'; // Saved to localStorage automatically
linkedSignal creates a writable computed you provide both a getter and a setter, so writes can fan out to multiple underlying signals:
import { linkedSignal, signal } from '@bquery/bquery/reactive';
const first = signal('Ada');
const last = signal('Lovelace');
const fullName = linkedSignal(
() => `${first.value} ${last.value}`,
(next) => {
const [nextFirst, nextLast] = next.split(' ');
first.value = nextFirst ?? '';
last.value = nextLast ?? '';
}
);
fullName.value = 'Grace Hopper'; // Fans out to first and last
Errors inside effects are caught and logged rather than crashing the reactive system subsequent updates keep working. That's a nice resilience property you don't always get for free.
5. Async Data & Fetching
Managing loading states, errors, and async lifecycles is boilerplate-heavy in vanilla JS. bQuery abstracts all of that into two composables.
useAsyncData wraps any async function in a signal-based lifecycle:
import { signal, useAsyncData } from '@bquery/bquery/reactive';
const userId = signal(1);
const user = useAsyncData(
() => fetch(`/api/users/${userId.value}`).then(r => r.json()),
{
watch: [userId], // re-run when userId changes
defaultValue: null,
onError: (err) => console.error('Failed:', err),
}
);
// Reactive state you can bind directly to the DOM
console.log(user.status.value); // 'idle' | 'pending' | 'success' | 'error'
console.log(user.pending.value); // boolean
console.log(user.data.value); // the resolved data
console.log(user.error.value); // Error | null
await user.refresh(); // manually trigger
user.clear(); // reset everything
user.dispose(); // stop watchers
useFetch builds on top of that and adds HTTP niceties base URLs, query params, custom headers, automatic JSON serialization, and pluggable response parsers (json, text, blob, arrayBuffer, formData, response):
import { useFetch } from '@bquery/bquery/reactive';
const users = useFetch('/users', {
baseUrl: 'https://api.example.com',
query: { page: 1, include: 'profile' },
headers: { authorization: 'Bearer my-token' },
});
For shared defaults across multiple requests, createUseFetch acts as a factory:
import { createUseFetch } from '@bquery/bquery/reactive';
const useApi = createUseFetch({
baseUrl: 'https://api.example.com',
headers: { 'x-client': 'my-app' },
});
const profile = useApi('/profile');
const posts = useApi('/posts', { query: { page: 2 } });
This pattern is really clean for larger apps where you want a pre-configured fetch instance rather than repeating base URLs everywhere.
6. Building Web Components with bQuery
The component module is where bQuery really shines for component-driven architectures. It wraps the native Custom Elements API with typed props, optional internal state, scoped reactivity, and a sanitized render function.
import { component, html, bool } from '@bquery/bquery/component';
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; border-radius: 8px; }
.active { border: 2px solid #4f46e5; }
`,
connected() {
console.log('user-card mounted');
},
disconnected() {
console.log('user-card removed');
},
render({ props, state, emit }) {
return html`
<button
class="card ${props.active ? 'active' : ''}"
${bool('disabled', !props.active)}
@click=${() => {
this.setState('clicks', state.clicks + 1);
emit('card-clicked', { username: props.username });
}}
>
<img src="${props.avatar}" alt="${props.username}" />
<strong>${props.username}</strong>
<span>Clicked ${state.clicks} times</span>
</button>
`;
},
});
<!-- Usage -->
<user-card username="Jonas" active></user-card>
A few things to appreciate here:
Props are typed and coerced automatically. Strings stay strings, numbers get Number() called on them, booleans understand 'true', '', '1', 'false', '0'. Objects get JSON.parsed. You can also add a validator function to enforce invariants at runtime.
The render output is sanitized before being written to the Shadow DOM. You get security by default with an explicit opt-in mechanism (safeHtml, trusted) when you need to pass sanitized fragments through.
Shadow DOM mode is configurable. Open shadow root by default, but you can go closed or render directly into light DOM:
component('inline-banner', {
shadow: false, // renders in light DOM
render: () => html`<p class="banner">No shadow needed here</p>`,
});
Lifecycle hooks cover everything you need: beforeMount, connected, beforeUpdate (return false to cancel a re-render), updated, disconnected, onError, onAdopted, and onAttributeChanged.
Scoped reactive helpers (useSignal, useComputed, useEffect) create component-local reactive resources that are cleaned up automatically on disconnect no manual cleanup needed:
component('live-timer', {
state: { seconds: 0 },
connected() {
const tick = useSignal(0);
const interval = setInterval(() => tick.value++, 1000);
useEffect(() => {
this.setState('seconds', tick.value);
});
this.disconnected = () => clearInterval(interval);
},
render({ state }) {
return html`<p>Elapsed: ${state.seconds}s</p>`;
},
});
External signals can drive re-renders via the signals option, keeping component updates predictable:
import { signal, computed } 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}">Current theme</span>`;
},
});
7. @bquery/ui The Default Component Library
bQuery ships a companion component library that's registered through registerDefaultComponents(). It's a small, zero-dependency set of native UI primitives no external CSS framework required.
import { defineBqueryConfig } from '@bquery/bquery/platform';
import { registerDefaultComponents } from '@bquery/bquery/component';
// Configure a custom prefix (default is 'ui')
defineBqueryConfig({
components: { prefix: 'ui' },
fetch: { baseUrl: 'https://api.example.com' },
transitions: { skipOnReducedMotion: true },
});
const tags = registerDefaultComponents();
console.log(tags);
// {
// button: 'ui-button',
// card: 'ui-card',
// input: 'ui-input',
// textarea: 'ui-textarea',
// checkbox: 'ui-checkbox'
// }
The available primitives:
-
ui-buttonpill-shaped button withvariant,size,type, anddisabledprops -
ui-cardcontainer with optionaltitle,footer, andelevatedprops -
ui-inputlabeled text input that emitsinputevents with{ value } -
ui-textarealabeled textarea, same event contract asui-input -
ui-checkboxlabeled checkbox that emitschangeevents with{ checked }
These components use regular HTML slots and bubble custom events, so they play nicely with forms, routers, and shadow DOM boundaries. You can compose them directly in your markup:
<ui-card title="Create Account">
<ui-input label="Name"></ui-input>
<ui-input label="Email"></ui-input>
<ui-checkbox label="Accept terms"></ui-checkbox>
<ui-button variant="primary" type="submit">Sign Up</ui-button>
</ui-card>
And wire them up reactively:
import { $, signal } from '@bquery/bquery';
const name = signal('');
const email = signal('');
document.querySelector('ui-input[label="Name"]')
?.addEventListener('input', (e) => {
name.value = (e as CustomEvent<{ value: string }>).detail.value;
});
The prefix system via defineBqueryConfig is a nice touch for teams with strict naming conventions, or for avoiding collisions when integrating bQuery components into an existing design system.
8. The Broader Ecosystem at a Glance
bQuery v1.7.0 covers a surprising amount of ground beyond what we've walked through. Here's a quick tour of the other modules:
Router full SPA routing with constrained params, guards, redirects, and declarative <bq-link> elements.
Store signal-based global state with persistence, migrations, and action lifecycle hooks. Think a lightweight Pinia, but framework-agnostic.
View declarative bq-* attribute directives (bq-text, bq-show, bq-class, etc.) for template-style binding without a full component.
Motion transitions, FLIP animations, springs, parallax, typewriter effects, and scroll-linked animations. Respects prefers-reduced-motion by default when configured.
i18n reactive locale state, nested translation keys, pluralization rules, and Intl-based date/number/relative-time formatting.
a11y focus traps, skip navigation links, live region announcers, and audit helpers that flag missing ARIA attributes at runtime.
DnD make any element draggable, define drop zones, build sortable lists without reaching for a third-party library.
Testing renderComponent, fireEvent, waitFor utilities that mirror what you'd expect from Testing Library.
SSR renderToString for server-side HTML generation and hydrateMount for seamlessly picking up where the server left off.
Storybook helpers storyHtml() and when() for writing safe Storybook stories with boolean attribute shorthand (?disabled=${true}).
9. When Should You Reach for bQuery?
bQuery isn't trying to replace React, Vue, or Svelte for large-scale applications with complex component trees and heavy state management. It's solving a different problem.
Reach for bQuery when:
- You want reactivity and component primitives without a build pipeline prototypes, experiments, browser extensions, internal tools
- You're writing vanilla JS/TS and want jQuery's ergonomics plus modern signal-based reactivity
- You need native Web Components with typed props and a sane lifecycle, but don't want to set up Lit or Stencil
- You're building progressively enhanced pages where a CDN import is all you need
- You want to ship accessible, secure-by-default UI without bolting on extra libraries for sanitization, focus management, and ARIA
- You're working on a small-to-medium project where a full SPA framework would be overkill
It's also genuinely useful as a companion in larger apps you could use bQuery's reactive core alongside an existing codebase for specific interactive islands without committing to a full rewrite.
10. Conclusion
bQuery v1.7.0 is one of those rare libraries that manages to feel both nostalgic and completely modern at the same time. It channels the simplicity of jQuery while embracing everything the web platform has become signals, Web Components, Trusted Types, fetch, Shadow DOM, the whole lot.
The zero-build path alone makes it worth knowing about. Being able to drop a single CDN import into an HTML file and immediately have signals, reactive DOM manipulation, typed components, and async data composables is genuinely impressive.
If you've been eyeing signals-based reactivity but felt the existing frameworks were too opinionated or too heavyweight for your use case, bQuery is absolutely worth exploring.
Give it a spin:
- ๐ยฆ npm:
@bquery/bquery - ๐ โ Docs: bquery.flausch-code.de
Thanks for reading! If you have questions or feedback, drop them in the comments. And if you're using bQuery in a project, I'd love to hear about it.
Top comments (0)