Reactivity Anywhere Without Virtual DOM
How Did This Come to Mind?
Virtual DOM introduced me to the concept behind this post's title. What if we set aside diffing and state management to focus on one thing: reactivity between JavaScript and the DOM? Most of us use libraries to achieve reactivity in our apps, but they implement a virtual DOM to track tags, states, variables, and objects before syncing with the real DOM. This can get complicated. So I wondered: could we implement a simple version without virtual DOM? The answer is (sort of) yes! Let's call it "Reactivity Anywhere."
Disclaimer
Some concepts in this post may seem unconventional. Please take this as an exploratory thought experiment. Reader discretion is advised.
Let's Start!
Prerequisites
- A web browser
- JavaScript
Defining the Global Variables (Our Stores)
We need some global variables to track and update our states.
const __refs__ = {};
const __reactives__ = [];
const __reactiveEl__ = document.querySelectorAll("[reactive]");
const reactive = (obj) => {
/*Add logic*/
};
const __updateDOM__ = (ref) => {
/*Add logic*/
};
This contains everything needed for our logic. The variables with double underscores are internals (like those in React's source code). We'll store just two things: element references and reactive variables.
But This Seems Like Virtual DOM!
Actually, this isn't the virtual DOM you're thinking of:
- We won't diff the entire element tree for every change—only the affected element will be updated (less carbon dioxide)
Determining reactive
Elements
To maintain specificity and avoid scanning the whole DOM, we'll select specific elements for our module. Only elements with the reactive
attribute (<element reactive></element>
) can use these reactive features.
To access reactive elements from the store, we'll use ES6 string interpolation syntax. For example, to access count
, we write:
<h1 reactive>The time is ${count}</h1>
The __refs__
Here, we store the values of the object passed to the reactive
function.
The __reactives__
This array contains live references to the DOM Elements.
The reactive()
This function serves as a store for all reactive elements. Its definition is surprisingly simple:
const reactive = (obj) => {
//Loop through the string
Object.keys(obj).forEach((key) => {
// defineProperty, anyone??
// We want to maintain reactivity, so we are using custom
// getters and setters
Object.defineProperty(__refs__, key, {
get() {
return obj[key];
},
// This shows an interesting aspect of the logic.
// This will update the target element everytime
// something changes.
set(val) {
obj[key] = val;
__updateDOM__(key);
},
});
// Think of this as an initial render
__updateDOM__(key);
});
// This is an important step otherwise
// everything is useless
return __refs__;
};
The __updateDOM__()
This is the Rosetta for the reactive
DOM Elements and __refs__
. The function definition is also straightforward:
// Ref can be any key from the __refs__ store
const __updateDOM__ = (ref) => {
// This is to check whether we want to update a specific ref value
if (ref) {
__reactives__
// filter out the elements we need
.filter((reactive) => reactive.dataset.ref === ref)
.forEach((reactive) => {
let ref = reactive.dataset.ref;
// Interacting with the DOM
// Nullish coalescing, anybody?
reactive.textContent = __refs__[ref] ?? "";
});
}
// UPDATE ALL!!
else
__reactives__.forEach((reactive) => {
let ref = reactive.dataset.ref;
// Interacting with the DOM
// Nullish coalescing, anybody?
reactive.textContent = __refs__[ref] ?? "";
});
};
Finding All Reactive Variables and Bootstrapping Them
We could wrap this in an IIFE (Immediately Invoked Function Expression), but for simplicity, let's keep it straightforward:
// Get live elements
const __reactiveEl__ = document.querySelectorAll("[reactive]");
__reactiveEl__.forEach((el) => {
// Match the `count` between <h1 reactive>${count}</h1>
const refs = el.innerHTML.match(/\${([^}]+)}/g);
// If the refs exist
if (refs) {
refs.forEach((ref) => {
// extract it
const dataRef = ref.split("{")[1].split("}")[0].trim();
// insert a special span element with the element
// and store the key name in the `data-ref` attribute
el.innerHTML = el.innerHTML.replace(
ref,
`<span class="reactivity-anywhere" data-ref="${dataRef}"></span>`
);
});
// Push the span element in __reactives__
__reactives__.push(...el.querySelectorAll("span.reactivity-anywhere"));
}
});
// Initialize all the magic!!
__updateDOM__();
Making input
and textarea
Work with Reactives
We need this functionality to handle user input in our code.
The enhanced textareas and input elements will use the ref
attribute.
This next section involves some complex operations, so let's dive in:
const parseDefaultRefValue = (el) => {
let parsed = null;
try {
// If the passed key is a function, run it
// and store the value
// I'm sorry, but we need to use eval here
parsed = eval(`(${el.getAttribute("ref-default")})()`);
} catch (e) {
parsed = el.getAttribute("ref-default");
}
return parsed;
};
const assignDefaultRefsToInputs = (el, ref) => {
__refs__[ref] = parseDefaultRefValue(el);
};
// Select input and textarea elements containing the
// 'ref' attribute, where the attr. value refers to any
// key in the __refs__ store.
// The `ref-default` contains the default value for the `ref`
// eg.
// <textarea ref="name"></textarea>
document.querySelectorAll("input[ref], textarea[ref]").forEach((el) => {
// Keep a ref to the ref!! Because we don't want to
// lose it in event listeners
const ref = el.getAttribute("ref");
if (ref) {
// lazily update default values
window.addEventListener("load", () => assignDefaultRefsToInputs(el, ref));
el.addEventListener("input", () => {
// again, a dumb reference to the ref
const elRef = ref;
// preserve default value
const defaultVal = parseDefaultRefValue(el);
// Set whatever value is typed as the ref value
// else, the default value
__refs__[elRef] = el.value !== "" ? el.value : defaultVal;
if (__refs__[elRef] !== defaultVal) {
// Keep rest of the input/textarea elements in sync
Array.from(document.querySelectorAll("input[ref], textarea[ref]"))
// Find input/textareas with same refs
.filter((el) => el.getAttribute("ref") === elRef)
// Keep their value in sync
.forEach((el) => (el.value = __refs__[elRef]));
}
});
}
});
We're Almost Done!
Now we just need to write some HTML to test everything. Here we go! A few more things to note:
- You can use multiple stores! However, if you redeclare a key in a later store, it takes precedence over the earlier one
Why This Approach Could Be Great (In My Opinion)
- It lets HTML and JavaScript each do what they do best. Rather than forcing "All HTML!" or "All JS!", it creates harmony between the two (and CSS) that respects each language's role.
- Minimal overhead. As mentioned earlier, no virtual DOM—just real DOM (credits to Svelte) with some objects in memory
Limitations
I encourage you to think critically about this approach :) since it's just a proof of concept. Feel free to analyze its strengths and weaknesses.
Ending Notes
If you're interested in building a framework with this concept, go for it (similar frameworks might already exist)! I'd be happy to help! Thanks for sticking with me through this long post!