Skip to content

Philosophy

Frontend frameworks make a tradeoff with state that is not dissimilar to the one you get with database tables and indexes:

  • With an index, reads are fast but writes are slow because after each change you have to update the index.
  • Without an index, writes are quick because you simply make the changes you want and you’re done. Reads, however, are slow.

With frontend frameworks, the tradeoff looks like this:

  • Reactive frameworks (like React) keep their state in a “pure” form that the framework monitors. Reading the state is fast: if you want to display an aggregation or prepare a payload for an AJAX request, the state is already available. Writing, however, is expensive: after each change, the framework re-renders the (virtual) DOM, doing everything in its power to make this a fast process.
  • domstatejsx keeps its state in the DOM. Writing is instantaneous: you simply change the DOM at the relevant place. For a checkbox or other input element, you don’t have to do anything — the browser takes care of “maintaining your state” for you. If you want to read your state, however, you have to inspect the DOM itself to retrieve it.

Let’s compare how the same feature — a list of posts you can upvote — is built in React and in domstatejsx.

In React, the state lives in a central variable. To change it, you create a new version of the state and bubble the change up through callback props.

function App() {
const [posts, setPosts] = useState({}); // { [id]: post }
function handlePostUpdate(post) {
setPosts((prev) => ({ ...prev, [post.id]: post }));
}
return Object.values(posts).map((post) => (
<Post key={post.id} value={post} onChange={handlePostUpdate} />
));
}
function Post({ value, onChange }) {
function handleUpVote() {
onChange({ ...value, voteCount: value.voteCount + 1 });
}
return (
<>
<h1>{value.title}</h1>
<p>{value.content}</p>
<button onClick={handleUpVote}>Upvote ({value.voteCount})</button>
</>
);
}

Every upvote creates a new state object and re-renders the tree. The state is always available in a clean, abstract form — but keeping it there is what the framework spends most of its time doing.

In domstatejsx, the state lives in the DOM. If a component has a DOM element that stores part of the state, it leaves it there. At best it exposes getters and setters through its context, or invokes prop callbacks if components higher up need access.

const initialPosts = [{ id: 1, title: '...', content: '...', voteCount: 0 }];
function App() {
// No state here. Each Post owns its state, in its DOM.
return initialPosts.map((post) => <Post value={post} onChange={console.log} />);
}
function Post({ value, onChange }) {
const refs = useRefs();
const [getTitle, setTitle] = useTextContent(refs.title);
const [getContent, setContent] = useTextContent(refs.content);
const [getVoteCount, setVoteCount] = useIntContent(refs.voteCount);
function get() {
return { title: getTitle(), content: getContent(), voteCount: getVoteCount() };
}
function set(value) {
setTitle(value.title);
setContent(value.content);
setVoteCount(value.voteCount);
}
function handleUpVote() {
setVoteCount((prev) => prev + 1);
onChange(get()); // setters write the DOM synchronously, so get() is already updated
}
return (
<Post.Context.Provider value={{ get, set }}>
<article>
<h1 ref={refs.title}>{value.title}</h1>
<p ref={refs.content}>{value.content}</p>
<button onClick={handleUpVote}>
Upvote (<span ref={refs.voteCount}>{value.voteCount}</span>)
</button>
</article>
</Post.Context.Provider>
);
}
Post.Context = createContext();

A few things to notice:

  • value is only an initial snapshot. App holds no state; each Post keeps its own state, in its own DOM.
  • Upvoting touches a single <span> — nothing else is re-rendered.
  • set is exposed through the context but unused here. It’s the hook that lets a parent initialize or reset a post’s state from the outside.
  • Because setters write to the DOM synchronously, get() called right after a setter already reflects the new value.

If you often need an abstract representation of your state — to send it in an AJAX request, store it in localStorage, implement undo, or compute aggregations — React is probably better. The state is already in the form you need, and a useEffect can react to any change reliably.

With domstatejsx, producing that abstract representation means reading the DOM and reconstructing it, and making sure all side effects fire at the right time is manual work.

  • Direct updates. If your state is mostly visible — what’s typed into an input, what’s checked, what’s shown — the browser maintains it for you. There’s no reconciliation step.
  • Tiny bundles. The entire domstatejsx library plus all the bundled examples in these docs compiles to about 7KB gzipped. React DOM alone is closer to 45KB. That’s nearly an order of magnitude smaller.

I don’t think domstatejsx is better than React in any general sense — I built this as an experiment. Here are the costs, without sugar-coating:

  • Manual side effects. If you want to save state to localStorage after every change, you have to remember to call save() at each point of change, in the right order. In React, a single useEffect takes care of it.
  • Mirrored state. If one piece of state must live in two places in the DOM (a checkbox and a summary counter), you have to keep them in sync. combineHooks helps by declaring that a single state is represented in two places, but it’s still manual.
  • Invisible state. If state must be remembered but isn’t visible — think of selected items in a paginated list — the DOM can’t hold it, and you’re back to writing code to manage it.
  • Hiding vs removing. Toggling a validation error means showing and hiding an element that stays in the DOM, rather than React’s {error && <p>{error}</p>}. Manageable, but a different mental model with unknown performance implications.

I could come up with hypothetical scenarios where domstatejsx outperforms React, but it would be disingenuous — real-world scenarios would probably favor React. I built this as an experiment because the idea was interesting. One genuine advantage stands: if you’re targeting tiny app builds, the bundled library is orders of magnitude smaller than React, and if you can work with this model to build somewhat complex applications, great.

You can read the original writeup here: Introducing domstatejsx.