12 September 2023

The Next Evolution in Frontend Frameworks

Update 10th November 2023

The change introduced in Svelte 5 wasn’t the kind of automatic reactivity that I predicted below, but it wasn’t far off. Apparently the Svelte team had already considered that kind of reactivity and decided to go with runes instead. I’ve also added a note on why my original proposition wouldn’t be quite as ergonomic as I originally envisioned, as stated.


Thousands of developer hours every day are poured into the task of trying to reconcile JavaScript’s non-reactive paradigm with the various reactivity models of modern frameworks: via Svelte’s = and stores; React’s hooks and diffing, Vue’s refs; and so on. All of this would be rendered obsolete by a framework that managed to build reactivity in at the ground level.

It’s amazing that we still don’t have a seamless way of saying “Here’s some data. Here’s some HTML that uses the data. Now update the DOM when the data changes, without bothering me with any of the plumbing involved.”

Svelte gets us some way towards this goal—assignment statements within components automatically trigger DOM updates—but it still doesn’t bother to track changes to objects or arrays that happen in other modules.

The mechanism for ground-up reactivity would be simple. At compile-time, every property assignment and array manipulation on a variable that could possibly be used in a component – according to a static analysis – would be augmented with a call to check whether it’s used in the DOM. As a low-hanging optimisation, any object that’s created within a function or module and doesn’t leave that scope could be omitted. That’s pretty much it.

someObj[someProp] = 123;

becomes:

someObj[someProp] = 123;
checkForReferences(someObj, someProp);

where checkForReferences does something like:

import objectReferences from "framework"; // global singleton

function checkForReferences(obj, prop) {
	const dependentRenderingContexts = objectReferences.get(obj)?.[prop];
	
	for (const context of dependentRenderingContexts) {
		context.rerender();
	}
}

The data structure for tracking which values are currently rendered could be a simple WeakMap mapping objects and properties to whatever the framework already uses for tracking mounted DOM elements. Much of this logic already exists in the Svelte compiler and runtime.

import objectReferences from "framework"; // global singleton

// when rendering an object property in a component:

objectReferences.set(someObj, {
	...objectReferences.get(someObj),
	[someProp]: [...objectReferences.get(someObj)[someProp], theCurrentRenderingContext],
});

After realising that this problem might actually be easier to solve than it first seems, I started wondering whether it could be what the Svelte team are referring to as the radical changes coming in Svelte 5. If not, I believe the first framework to implement it will gain a very large following very quickly.


Update

After thinking about the problem more deeply, I realised you would need something like runes or signals in order to be able to specify the dependency graph and which variables should be reactive. This is necessary for displaying bits of state where not all dependencies are explicitly stated in the view, as with a method call:

<div>{obj.someMethod()}</div>

someMethod can use whatever object properties or global state it wants, and we won’t know to re-render (or whether to re-render) when those variables are updated. So we need either runes/signals/whatever, or else an explicit dependency array at the point of use – e.g. {obj.someMethod() using [obj.prop1, obj.prop2, someGlobal]} – but that of course lacks the “not having to think about it” quality that you’d get if all view state was just variables.

In light of this, explicit reactivity—and runes in particular—starts to look like a very appealing trade-off.