@algosail/dom
v0.1.0
Published
Small Reactive DOM framework built on @algosail/stream.
Readme
@algosail/dom
Reactive DOM utilities built on @algosail/stream. Create elements, bind streams to attributes/properties, and manage component lifecycles — no virtual DOM, direct DOM manipulation only.
Contents
Scope: scope
Event sources: fromEvent · fromResize · fromIntersection · fromMutation
Event helpers: preventDefault · stopPropagation · targetValue · targetChecked · eventKey
Element creation: el · svgEl · events · component
Control flow: when · match · mount
Element shortcuts: div, span, button, input, and all standard HTML/SVG tags.
Tutorial
This tutorial walks through building a small interactive app — a counter with a reset button — to cover the key concepts: elements, events, reactive state, and mounting.
1. Creating elements
Use el(tag, props, children) to create DOM elements. Children can be strings, other Views, or streams.
import { el, mount } from '@algosail/dom'
const v = el('div', { class: 'box' }, [el('h1', {}, ['Hello, world!'])])
mount(v, document.body)No virtual DOM — el returns a View wrapping a real HTMLElement. mount appends it to the given parent.
2. Handling events and reactive state
Use bus() from @algosail/stream to create an event source you can push into imperatively. Use reduce to accumulate state into a held stream that replays its latest value to new subscribers.
import { el, mount } from '@algosail/dom'
import { bus, reduce, map } from '@algosail/stream'
const [click$, dispatch] = bus()
// count$ holds the current count and replays it on subscribe
const count$ = reduce((n, _) => n + 1, 0)(click$)
const counter = el('div', {}, [
el('button', { 'on:click': dispatch }, ['Increment']),
el('p', {}, [map((n) => `Count: ${n}`)(count$)]),
])
mount(counter, document.body)'on:click': dispatch wires the native event listener directly into the stream. The text inside <p> updates automatically whenever count$ emits.
3. Deriving and transforming streams
Any stream operator from @algosail/stream works with DOM streams. Here we add a reset button:
import { el, mount } from '@algosail/dom'
import { bus, reduce, map, merge } from '@algosail/stream'
const [inc$, increment] = bus()
const [rst$, reset] = bus()
const count$ = reduce(
(n, action) => (action === 'reset' ? 0 : n + 1),
0,
)(merge(map(() => 'inc')(inc$), map(() => 'reset')(rst$)))
const counter = el('div', {}, [
el('button', { 'on:click': increment }, ['+']),
el('button', { 'on:click': reset }, ['Reset']),
el('p', {}, [map((n) => `Count: ${n}`)(count$)]),
])
mount(counter, document.body)4. Building reusable components
component creates a factory function. It receives props and a scope that automatically disposes all subscriptions when the component is removed from the DOM.
import { component, el, mount } from '@algosail/dom'
import { bus, reduce, map } from '@algosail/stream'
const Counter = component((props, s) => {
const [click$, dispatch] = bus()
const count$ = reduce((n, _) => n + 1, props.initial ?? 0)(click$)
// s.own ensures this subscription is cleaned up when the component unmounts
s.own(count$)
return {
view: el('div', {}, [
el('button', { 'on:click': dispatch }, ['+']),
el('p', {}, [map((n) => `Count: ${n}`)(count$)]),
]),
}
})
mount(Counter({ initial: 10 }), document.body)5. Conditional rendering and lists
Use when for boolean toggling and keyed for efficiently rendering lists:
import { component, el, when, keyed, mount } from '@algosail/dom'
import { bus, reduce, map, hold, wrap } from '@algosail/stream'
const [toggle$, toggle] = bus()
const visible$ = reduce((v, _) => !v, true)(toggle$)
const items$ = hold(
wrap([
{ id: 1, text: 'Buy milk' },
{ id: 2, text: 'Write code' },
]),
)
const app = el('div', {}, [
el('button', { 'on:click': toggle }, ['Toggle list']),
when(visible$, () =>
el('ul', {}, [
keyed(
items$,
(item) => item.id,
(item) => el('li', {}, [item.text]),
),
]),
),
])
mount(app, document.body)keyed reuses existing DOM nodes on re-render, only creating/disposing nodes for items that were added or removed.
6. Scoped CSS
Use @algosail/styles to co-locate scoped styles with your component:
import { component, el, mount } from '@algosail/dom'
import { css } from '@algosail/styles'
const s = css`
.root {
display: flex;
gap: 8px;
align-items: center;
}
.count {
font-size: 2rem;
font-weight: bold;
}
.button {
padding: 4px 12px;
}
`
const Counter = component((props) => ({
view: el('div', { class: s.root }, [
el('button', { class: s.button }, ['-']),
el('span', { class: s.count }, ['0']),
el('button', { class: s.button }, ['+']),
]),
}))Class names are automatically scoped — .button_3, .count_3, etc. — so styles from different components never clash.
Views
view
view :: HTMLElement -> Disposable[] -> ViewWraps a DOM element and a list of disposables into a View. Disposing the View calls all child disposables.
const element = document.createElement('div')
const dsp = fromEvent(element, 'click').pipe(...)
const v = view(element, [dsp])
// Later:
dispose(v) // cleans up all disposables and removes listenersisView
isView :: unknown -> BooleanTrue when the value is a View object.
isView(el('div')) // => true
isView({}) // => false
isView(null) // => falsenode
node :: View -> HTMLElementExtracts the underlying DOM node from a View.
const v = el('div', { class: 'box' })
document.body.appendChild(node(v))Scope
scope
scope :: () -> ScopeCreates a resource scope that collects disposables and cleans them all up at once. Use inside components to tie resource lifetimes to the component's lifecycle.
const s = scope()
s.own(forEach(v => { el.textContent = v })(text$))
s.own(fromEvent(document, 'keydown').pipe(...))
// When component unmounts:
dispose(s) // all owned disposables cleaned upEvent Sources
fromEvent
fromEvent :: EventTarget -> String -> AddEventListenerOptions? -> Stream EventCreates a stream of DOM events.
const click$ = fromEvent(button, 'click')
const input$ = fromEvent(textInput, 'input')
const keys$ = fromEvent(document, 'keydown', { capture: true })fromResize
fromResize :: Element -> Stream ResizeObserverEntry[]Creates a stream of ResizeObserver entries for the element.
const size$ = map(([e]) => ({
w: e.contentRect.width,
h: e.contentRect.height,
}))(fromResize(container))fromIntersection
fromIntersection :: Element -> IntersectionObserverInit? -> Stream IntersectionObserverEntry[]Creates a stream of IntersectionObserver entries.
const visible$ = map(([e]) => e.isIntersecting)(
fromIntersection(lazyImage, { threshold: 0.1 }),
)fromMutation
fromMutation :: Node -> MutationObserverInit? -> Stream MutationRecord[]Creates a stream of MutationObserver records.
const childChanges$ = fromMutation(container, { childList: true })
const attrChanges$ = fromMutation(el, {
attributes: true,
attributeFilter: ['class'],
})Event Helpers
preventDefault
preventDefault :: Stream Event -> Stream EventCalls preventDefault on every event and passes it through.
const submit$ = preventDefault(fromEvent(form, 'submit'))stopPropagation
stopPropagation :: Stream Event -> Stream EventCalls stopPropagation on every event and passes it through.
const click$ = stopPropagation(fromEvent(modal, 'click'))targetValue
targetValue :: Stream Event -> Stream StringExtracts event.target.value from every event.
const value$ = targetValue(fromEvent(input, 'input'))targetChecked
targetChecked :: Stream Event -> Stream BooleanExtracts event.target.checked from every event.
const checked$ = targetChecked(fromEvent(checkbox, 'change'))eventKey
eventKey :: Stream KeyboardEvent -> Stream StringExtracts the key name from every keyboard event.
const enter$ = filter((k) => k === 'Enter')(
eventKey(fromEvent(input, 'keydown')),
)Lists
each
each :: Stream a[] -> (a -> View) -> Stream View[]Re-renders the whole list on every change. Previous Views are disposed automatically.
const items$ = hold(wrap([{ id: 1, name: 'Alice' }]))
each(items$, (item) => el('li', {}, [item.name]))keyed
keyed :: Stream a[] -> (a -> String|Number) -> (a -> View) -> Stream View[]Caches Views by key. Existing nodes are reused on reorder or partial update — only new items are created, only removed items are disposed.
keyed(
users$,
(user) => user.id,
(user) => el('li', {}, [user.name]),
)Element Creation
el
el :: String -> Record -> Child[] -> ViewCreates a reactive HTML element. Props support:
- Static attributes:
{ class: 'foo', id: 'bar' } - DOM properties via
.prefix:{ '.value': text$ } - Event listeners via
on:prefix:{ 'on:click': handler } - Reactive streams for any prop/attribute:
{ class: class$ } - Nested
styleobject:{ style: { color: 'red', opacity: opacity$ } }
Children can be strings, numbers, Views, arrays, or Streams of any of those.
el('div', { class: 'box', style: { color: 'red' } }, ['Hello'])
el('input', {
type: 'text',
'.value': text$,
'on:input': (e) => dispatch(e.target.value),
})
el('div', {}, [
map((count) => `Count: ${count}`)(count$), // reactive text
])svgEl
svgEl :: String -> Record -> Child[] -> ViewSame as el but creates SVG elements in the SVG namespace.
svgEl('circle', { cx: '50', cy: '50', r: '40', fill: 'blue' })
svgEl('svg', { viewBox: '0 0 100 100' }, [
svgEl('rect', { width: '100', height: '100', fill: 'lightgray' }),
])events
events :: View -> String -> AddEventListenerOptions? -> Stream EventShorthand for fromEvent scoped to a View's underlying node.
const v = el('button', {}, ['Click me'])
const click$ = events(v, 'click')component
component :: ((props, Scope) -> { view, ...extras }) -> (props?) -> View & extrasWraps a setup function into a reusable component factory. scope auto-disposes with the component.
const Counter = component((props, s) => {
const [click$, dispatch] = bus()
const count$ = reduce((n, _) => n + 1, props.initial ?? 0)(click$)
return {
view: el('button', { 'on:click': dispatch }, [
map((n) => `Count: ${n}`)(count$),
]),
}
})
mount(Counter({ initial: 5 }), document.body)Control Flow
when
when :: Stream Boolean -> (() -> View) -> (() -> View)? -> Stream (View | null)Conditionally renders one of two factories based on a boolean stream.
el('div', {}, [
when(
isLoggedIn$,
() => el('span', {}, ['Welcome!']),
() => el('a', { href: '/login' }, ['Sign in']),
),
])match
match :: Stream K -> Record<K, () -> View> -> Stream (View | null)Renders the factory matching the current key; null if no match.
el('main', {}, [
match(tab$, {
home: () => HomeView(),
about: () => AboutView(),
contact: () => ContactView(),
}),
])mount
mount :: View | Stream Child -> HTMLElement -> DisposableMounts a View or stream of children into a parent element. Returns a Disposable to unmount.
const dsp = mount(App(), document.getElementById('app'))
// Later, to unmount:
dispose(dsp)Element Shortcuts
All standard HTML elements are available as shorthand functions. They accept either (props, children) or just (children):
div({ class: 'box' }, [span(['hello'])])
button({ 'on:click': handleClick }, ['Submit'])
input({ type: 'email', '.value': email$ })
ul([li(['item 1']), li(['item 2'])])HTML elements: div, span, p, h1–h6, a, button, input, textarea, select, option, form, label, ul, ol, li, table, tr, td, th, thead, tbody, tfoot, img, video, audio, nav, header, footer, main, section, article, aside, details, summary, code, pre, blockquote, strong, em, small, br, hr, style, script, and more.
SVG elements: svg, g, path, circle, rect, line, polyline, polygon, ellipse, defs, clipPath, mask, pattern, linearGradient, radialGradient, stop, use.
