@minimact/query
v0.1.1
Published
SQL query interface for DOM state - treat the DOM as a relational database
Maintainers
Readme
Minimact Query
SQL for the DOM - Query the DOM like a relational database.
Treat the DOM as a reactive, queryable data source with full SQL-like syntax. Built on top of minimact-punch, which makes the DOM a comprehensive state system with 80+ queryable properties.
Installation
npm install minimact-query minimact-punchQuick Start
import { useDomQuery } from 'minimact-query';
function MyComponent() {
// Query the DOM with SQL-like syntax
const query = useDomQuery()
.from('.card')
.where(card => card.isIntersecting && card.state.hover)
.orderBy(card => card.history.changeCount, 'DESC')
.limit(10);
// SELECT projection in JSX
return (
<div>
{query.select(card => ({
id: card.attributes.id,
title: card.textContent,
changes: card.history.changeCount
})).map(row => (
<div key={row.id}>
{row.title} - {row.changes} changes
</div>
))}
</div>
);
}Features
✅ Full SQL-Like Syntax
- SELECT - Project results to any shape
- FROM - Query by CSS selector
- WHERE - Filter with predicates
- JOIN - Relate elements to each other
- GROUP BY - Aggregate elements
- HAVING - Filter groups
- ORDER BY - Sort results
- LIMIT/OFFSET - Pagination
- DISTINCT - Unique values
- UNION/INTERSECT/EXCEPT - Set operations
✅ Aggregate Functions
COUNT()- Count resultsSUM()- Sum numeric valuesAVG()- AverageMIN()/MAX()- Min/max valuesSTDDEV()- Standard deviation
✅ Reactive by Default
- Queries automatically re-run when DOM changes
- Built-in throttling and debouncing options
- Optimized for performance
✅ Type-Safe
- Full TypeScript support
- Autocomplete for all DOM properties
- Inferred return types
✅ 80+ Queryable Properties
Access all minimact-punch properties:
- Base:
isIntersecting,childrenCount,attributes,classList - Pseudo-State:
state.hover,state.focus,state.active - Theme:
theme.isDark,theme.reducedMotion,breakpoint.md - History:
history.changeCount,history.hasStabilized,history.trend - Lifecycle:
lifecycle.lifecycleState,lifecycle.timeInState
API
useDomQuery()
Reactive React hook that creates a query builder. Automatically re-runs when DOM changes.
const query = useDomQuery()
.from('.selector')
.where(el => /* predicate */)
.orderBy(el => /* value */, 'DESC')
.limit(10);useDomQueryThrottled(ms)
Throttled version - limits re-renders to once every N milliseconds.
const query = useDomQueryThrottled(250) // Max 4 updates/second
.from('.live-data');useDomQueryDebounced(ms)
Debounced version - only re-renders after N milliseconds of inactivity.
const query = useDomQueryDebounced(500) // Wait for 500ms quiet
.from('.search-results');useDomQueryStatic()
Non-reactive version - only runs once.
const query = useDomQueryStatic()
.from('.card');Query Methods
FROM
Specify which elements to query (CSS selector):
query.from('.card')
query.from('#app')
query.from('[data-type="widget"]')
query.from('div.active')WHERE
Filter elements by predicate:
// Single condition
query.where(card => card.state.hover)
// Multiple conditions (chained = AND)
query
.where(card => card.isIntersecting)
.where(card => card.childrenCount > 5)
.where(card => card.lifecycle.lifecycleState === 'visible')
// Complex boolean logic
query.where(card =>
(card.state.hover || card.state.focus) &&
card.theme.isDark &&
card.history.changeCount > 10
)Shorthand methods:
query.whereEquals('lifecycle.lifecycleState', 'visible')
query.whereGreaterThan('childrenCount', 10)
query.whereLessThan('history.changeCount', 5)
query.whereBetween('childrenCount', 5, 10)
query.whereIn('lifecycle.lifecycleState', ['visible', 'entering'])JOIN
Relate elements to each other:
// INNER JOIN - only matching elements
query
.from('.card')
.join(
useDomQuery().from('.badge'),
(card, badge) => card.element.contains(badge.element)
)
// LEFT JOIN - all left elements, matching right or null
query
.from('.product')
.leftJoin(
useDomQuery().from('.review'),
(product, review) =>
product.attributes['data-id'] === review.attributes['data-product-id']
)GROUP BY
Group elements by a key function:
query
.from('.widget')
.groupBy(w => w.lifecycle.lifecycleState)
// Access grouped results
query.select(group => ({
state: group.key,
count: group.count,
items: group.items
}))HAVING
Filter groups after grouping:
query
.from('.product')
.groupBy(p => p.attributes['data-category'])
.having(group => group.count > 10) // Only categories with 10+ productsORDER BY
Sort results:
query.orderBy(card => card.history.changeCount, 'DESC')
query.orderBy(card => card.childrenCount, 'ASC')
// Multiple sort keys (chain them)
query
.orderBy(card => card.attributes.category, 'ASC')
.orderBy(card => card.history.ageInSeconds, 'DESC')LIMIT / OFFSET
Pagination:
query.limit(10) // First 10 results
query.limit(10, 20) // Skip 20, take 10
query.offset(20).limit(10) // Same as aboveSELECT
Project results to a new shape (called in JSX):
{query.select(card => ({
id: card.attributes.id,
title: card.textContent,
isHovered: card.state.hover,
changes: card.history.changeCount
})).map(row => (
<div key={row.id}>...</div>
))}Set Operations
// UNION - combine results (no duplicates)
const buttons = useDomQuery().from('button');
const links = useDomQuery().from('a');
const interactive = buttons.union(links);
// INTERSECT - elements in both queries
const hovered = useDomQuery().from('.item').where(el => el.state.hover);
const focused = useDomQuery().from('.item').where(el => el.state.focus);
const both = hovered.intersect(focused);
// EXCEPT - elements in first query but not second
const allCards = useDomQuery().from('.card');
const visible = useDomQuery().from('.card').where(c => c.isIntersecting);
const hidden = allCards.except(visible);
// DISTINCT - unique values
query.distinct(item => item.attributes['data-category'])Aggregate Functions
query.count() // Total count
query.sum(card => card.childrenCount) // Sum
query.avg(card => card.childrenCount) // Average
query.min(card => card.childrenCount) // Minimum
query.max(card => card.childrenCount) // Maximum
query.stddev(card => card.childrenCount) // Standard deviation
query.first() // First result or null
query.last() // Last result or null
query.any() // Has any results?
query.all(card => card.isIntersecting) // All match condition?
query.find(card => card.state.hover) // Find first matchingReal-World Examples
Dashboard Analytics
function DashboardStats() {
const stats = useDomQuery()
.from('.metric-card')
.where(card => card.isIntersecting)
.groupBy(card => card.lifecycle.lifecycleState);
return (
<div>
{stats.select(group => ({
state: group.key,
count: group.count
})).map(row => (
<div key={row.state}>
{row.state}: {row.count} cards
</div>
))}
</div>
);
}Performance Monitoring
function PerformanceMonitor() {
const unstable = useDomQuery()
.from('.component')
.where(c => c.history.changesPerSecond > 10)
.orderBy(c => c.history.volatility, 'DESC');
return (
<div>
{unstable.any() && (
<Alert>
{unstable.count()} unstable components detected!
</Alert>
)}
</div>
);
}Top 10 Most Active Elements
function MostActive() {
const query = useDomQuery()
.from('.interactive')
.orderBy(el => el.history.changeCount, 'DESC')
.limit(10);
return (
<div>
{query.select(el => ({
id: el.attributes.id,
changes: el.history.changeCount
})).map((row, i) => (
<div key={row.id}>
#{i + 1}: {row.id} - {row.changes} changes
</div>
))}
</div>
);
}Accessibility Audit
function AccessibilityAudit() {
const unlabeled = useDomQuery()
.from('button')
.where(btn =>
!btn.attributes['aria-label'] &&
!btn.textContent?.trim()
);
const improperDisabled = useDomQuery()
.from('input, button')
.where(el =>
el.state.disabled &&
!el.attributes['aria-disabled']
);
return (
<div>
<p>Unlabeled buttons: {unlabeled.count()}</p>
<p>Improperly disabled: {improperDisabled.count()}</p>
</div>
);
}SQL Equivalents
| Minimact Query | SQL Equivalent |
|---|---|
| useDomQuery().from('.card') | SELECT * FROM .card |
| .where(c => c.isIntersecting) | WHERE isIntersecting = true |
| .orderBy(c => c.childrenCount, 'DESC') | ORDER BY childrenCount DESC |
| .limit(10) | LIMIT 10 |
| .groupBy(c => c.attributes.category) | GROUP BY category |
| .having(g => g.count > 10) | HAVING COUNT(*) > 10 |
| .count() | SELECT COUNT(*) |
| .avg(c => c.childrenCount) | SELECT AVG(childrenCount) |
| .union(other) | UNION |
| .intersect(other) | INTERSECT |
| .except(other) | EXCEPT |
Why This is Brilliant
- Familiar Syntax - If you know SQL, you already know Minimact Query
- Type-Safe - Full TypeScript support with autocomplete
- Reactive - Queries automatically update when DOM changes
- Performant - Optimized execution with throttling/debouncing options
- Composable - Save and reuse queries
- Powerful - 80+ properties from minimact-punch
- Clean Separation - Data fetching in hook, projection in JSX
- Production Ready - Built, tested, documented
Documentation
- EXAMPLES.md - 24 comprehensive examples
- DOM_SQL_DESIGN.md - Architecture and design
The Stack
minimact (core framework)
↓
minimact-punch (DOM as reactive data source)
↓
minimact-query (SQL interface for querying)License
MIT
The DOM is now a relational database. 🗃️⚡
Query it like PostgreSQL. Make it reactive. Make it type-safe.
Welcome to the future of DOM interaction.
