Skip to content

adamzwasserman/domx

domx

DOM state observer for DATAOS — collect, apply, observe, and persist DOM state.

< 1KB minified + gzipped. Zero dependencies.

What is domx?

domx implements the DATAOS principle: DOM as the single source of truth.

Instead of syncing JavaScript state with DOM state (and inevitably getting them out of sync), domx reads state directly from the DOM when needed. No Redux. No MobX. No useState. Just the DOM.

Security Considerations

⚠️ Important Security Notes

  • Avoid storing sensitive data: State cached to localStorage is accessible to any script on the same domain. Do not include passwords, tokens, or other sensitive information in manifests used with send() or HTMX caching.
  • Use static manifests: Define manifests in code, not dynamically from user input, to prevent selector injection attacks.
  • Safe custom functions: When using custom read/write functions, avoid unsafe DOM methods like innerHTML. Stick to the provided shortcuts for security.
  • Server-controlled attributes: Ensure dx-manifest attributes are rendered server-side, not set by user input, to prevent code injection.
// Define what state lives where in the DOM
const manifest = {
  searchQuery: { selector: '#search', read: 'value' },
  sortDir: { selector: '[data-sort]', read: 'attr:data-sort-dir' },
  filters: { selector: '.filter.active', read: 'data:filter' }
};

// Collect state from DOM
const state = domx.collect(manifest);
// → { searchQuery: "hello", sortDir: "asc", filters: ["status", "priority"] }

// Send to server
const response = await domx.send('/api/search', manifest);

Installation

npm install domx

Or via CDN:

<script src="https://unpkg.com/domx"></script>

Quick Start

1. Define a manifest

The manifest maps state labels to DOM selectors and read/write methods:

const manifest = {
  username: { selector: '#username', read: 'value', write: 'value' },
  rememberMe: { selector: '#remember', read: 'checked', write: 'checked' },
  theme: { selector: '[data-theme]', read: 'data:theme', write: 'data:theme' }
};

2. Collect state

const state = domx.collect(manifest);
// → { username: "alice", rememberMe: true, theme: "dark" }

3. Apply state

domx.apply(manifest, { username: "bob", theme: "light" });
// DOM is updated

4. Observe changes

const unsubscribe = domx.observe(manifest, (state) => {
  console.log('State changed:', state);
});

// Later: stop observing
unsubscribe();

API Reference

collect(manifest)

Reads current DOM state based on manifest. Returns object with labels as keys.

const state = domx.collect(manifest);

apply(manifest, state)

Writes state values to DOM. Only processes entries with write key.

domx.apply(manifest, { username: "alice" });

observe(manifest, callback)

Watches DOM for changes and calls callback with full state. Auto-detects watch mechanism from read type. Returns unsubscribe function.

const unsubscribe = domx.observe(manifest, (state) => {
  // Called on any relevant DOM change
});

on(callback)

Low-level subscription to raw MutationRecords. For framework integration (e.g., genX modules).

const unsubscribe = domx.on((mutations) => {
  // Process raw mutations
});

send(url, manifest, opts?)

Collects state, caches to localStorage, and sends via fetch.

⚠️ Security Warning: Cached state in localStorage is accessible to any script on the same domain. Avoid including sensitive data in manifests used with this function.

const response = await domx.send('/api/save', manifest, {
  headers: { 'X-Custom': 'value' }
});

replay()

Re-sends cached request (for page refresh recovery). Returns null if no valid cache.

// On page load
const response = await domx.replay();
if (response?.ok) {
  const html = await response.text();
  container.innerHTML = html;
}

clearCache()

Clears the cached request.

domx.clearCache();

Manifest Format

Read/Write Shortcuts

Shortcut Read Write
"value" el.value el.value = x
"checked" el.checked el.checked = x
"text" el.textContent el.textContent = x
"attr:name" el.getAttribute('name') el.setAttribute('name', x)
"data:name" el.dataset.name el.dataset.name = x
Function Custom extractor Custom writer

Custom Functions

For complex cases, pass a function:

⚠️ Security Warning: Custom functions have full access to DOM elements. Avoid using unsafe methods like innerHTML to prevent XSS attacks.

const manifest = {
  combined: {
    selector: '#thing',
    read: (el) => `${el.dataset.foo}-${el.dataset.bar}`,
    write: (el, val) => {
      const [foo, bar] = val.split('-');
      el.dataset.foo = foo;
      el.dataset.bar = bar;
    }
  }
};

Multiple Elements

When selector matches multiple elements, collect() returns an array:

const manifest = {
  tags: { selector: '.tag', read: 'text' }
};

const state = domx.collect(manifest);
// → { tags: ["JavaScript", "TypeScript", "Python"] }

htmx Integration

domx includes an htmx extension for seamless integration:

<script src="domx.js"></script>
<script src="domx-htmx.js"></script>

<script>
const manifest = {
  searchQuery: { selector: '#search', read: 'value' },
  sortDir: { selector: '[data-sort]', read: 'attr:data-sort-dir' }
};
</script>

<body hx-ext="domx" dx-manifest="manifest" dx-cache="true">
  <input id="search" type="text">
  <button data-sort data-sort-dir="asc" hx-post="/api/search" hx-trigger="click">
    Search
  </button>
</body>

Features

  • Auto state collection: State is automatically added to request parameters
  • dx-cache: When true, caches state to localStorage and auto-replays on page refresh (⚠️ avoid sensitive data)
  • dx:change event: Fires when any observed state changes (use with hx-trigger="dx:change")

Attributes

Attribute Description
dx-manifest Manifest object name or inline JSON
dx-cache Enable localStorage caching ("true"/"false")

⚠️ Security Warning: dx-manifest attributes should be server-rendered, not user-settable, to prevent potential code injection through JSON parsing or window property access.

Page Refresh Handling

domx solves the "lost state on refresh" problem:

  1. Before request: send() caches state to localStorage
  2. On refresh: replay() re-sends the cached request
  3. Server responds: Fresh HTML with correct state
// On page load
document.addEventListener('DOMContentLoaded', async () => {
  const response = await domx.replay();
  if (response?.ok) {
    const html = await response.text();
    document.getElementById('container').innerHTML = html;
  }
});

Comparison with stateless (React)

stateless (React) domx (Vanilla)
useDomState(manifest) collect(manifest)
useDomValue() setter apply(manifest, state)
Hook re-render on mutation observe(manifest, callback)

Both implement DATAOS principles. Use stateless for React apps, domx for vanilla JS or htmx apps.

Performance

  • Single MutationObserver: Regardless of manifest size
  • Batched callbacks: Uses requestAnimationFrame to batch rapid changes
  • Passive event listeners: For input/change events
  • < 1KB: Minified + gzipped

Related Projects

  • DATAOS - The philosophy behind domx
  • stateless - React implementation of DATAOS
  • genX - Declarative HTML formatting library (uses domx)
  • htmx - High power tools for HTML
  • multicardz - DATAOS in production

License

MIT © Adam Zachary Wasserman

About

DOM state observer for DATAOS — collect, apply, observe, and persist DOM state

Resources

License

Code of conduct

Contributing

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors