The web development experience has evolved dramatically. Over the past decade, we've witnessed an explosion of JavaScript frameworks designed to tackle various engineering challenges in UI development and streamline the lifecycle of building scalable web applications. Yet, amidst this rapid display of the engineering prowess by developers, teams, and companies. It is essential to pause and reflect on the Goodhart's Law: when a measure becomes a target, it ceases to be a good measure.
The React Debate
Since React's introduction in 2013, debates about the overhead of the virtual DOM have persisted. This has spurred the creation of many frameworks aimed at optimizing this issue, each introducing its own set of overheads. Despite these innovations, web development remains challenging for both newcomers and seasoned developers alike. To accelerate the adoption of cutting-edge web APIs, such as WebAssembly, the engineering challenges that these frameworks attempt to solve must be fundamentally addressed.
I recently lost faith in React after reading Dan Abramov's blog post. Its current direction poses a significant obstacle to the advancement of the web development community. React has strayed from its roots and is now encroaching on server-side operations, which is concerning on so many levels.
Introducing "The Missing Web API"
To address these persistent challenges, I propose a set of ideas that could fundamentally transform the way we build web applications. I call this "The Missing Web API."
1. Declarative Control-Flow
Given the declarative nature of HTML, it's crucial to have natively supported control flow mechanisms. Currently, an HTML element describes and renders its direct child elements in sequence, representing a sequential control flow. By introducing two new HTML attributes: composed
and has
, we can describe more complex control flow natively, and offload rendering to the Web Component lifecycle. For example:
<div composed has="selection">
<div>Loading...</div>
<div>Hello world!</div>
</div>
In this snippet, the composed
attribute indicates that the <div>
element has a different rendering scheme, while the has
attribute specifies the identifier for the rendering scheme. Below is another example:
<div composed has="iteration">
<div>Item <span></span></div>
</div>
As you might have guessed, the above HTML element iteratively renders its children and assigns the item key to the enclosed span's textContent
. We'll demonstrate this in a later example, but the main idea is that we can describe various control flows.
One key property of composed elements is that they always use the ShadowDOM for rendering their children. Therefore, the examples given above will be rendered as shown below:
<div composed has="selection">
#shadow
<div>Hello world!</div>
<div>Loading...</div>
<div>Hello world!</div>
</div>
<div composed has="iteration">
#shadow
<div cid="1">Item <span>1</span></div>
<div cid="2">Item <span>2</span></div>
<div cid="3">Item <span>3</span></div>
<div>Item <span></span></div>
</div>
The well-known FOUC issue (Flash of Unstyled Content) is addressed by attaching Shadow DOM during the initialization of composed elements, rendering an empty view initially, as shown below:
<div composed has="selection">
#shadow
<!-- empty -->
<div>Loading...</div>
<div>Hello world!</div>
</div>
The novelty of this idea is its minimalistic obstruction of the HTML syntax, especially when compared to Angular's recently introduced built-in control flow.
2. Zero-way Data Binding
Data binding automatically synchronizes data between model and view components. Unlike traditional one-way and two-way data binding, I propose streamlining view updates through a strict API. By introducing compose
and oncompose
methods for composed elements, we can effectively update the view state. This approach, which I call zero-way data binding, is a subset of Event-Based Data Binding. To clarify further, see the following example:
<div id="sample" composed>
<span id="greeting"></span>
</div>
let element = document.getElementById("sample");
element.oncompose = function(event) {
if (event.detail.payload.greet){
const greetingElement = event.target.getElementById("greeting");
greetingElement.textContent = event.detail.payload.greeting;
};
};
const payload = { greet: true, greeting: "Hello World!" };
element.compose(payload);
Here, the element is not bound to any specific data. Instead, the compose
method is triggered, taking an arbitrary payload to update its view state. This payload is passed as part of the Compose Event discussed below. You can think of the payload as an ActorModel, ViewModel, or a simple model.
3. Compose Event
The ComposeEvent
is an extension of the CustomEvent API. Unlike typical web events, it propagates top-down through the hierarchy of composed child elements. Building on the previous example:
<div id="app" composed has="selection">
<div>Loading...</div>
<div>
<div composed has="selection">
...
</div>
<div composed>
...
</div>
</div>
</div>
When the top parent compose
is triggered, the event propagates with the payload down to the composed child elements. Each child element will then handle the event, change its view state, and modify the payload according to its oncompose
function.
4. Reference Fragment
The ReferenceFragment has a similar API to the DocumentFragment and the Range API. It always holds a reference to a parent element. When initialized, it holds a parent reference to a DocumentFragment. Once added to the DOM, it updates its parent reference accordingly. This allows us to keep a reference to a set of nodes even after they are inserted into the DOM tree. An example usage is shown below:
let referenceFragment = new ReferenceFragment();
referenceFragment.appendChild(document.createElement('div'));
referenceFragment.appendChild(document.createElement('span'));
console.log(referenceFragment.parentNode);
// output: DocumentFragment
console.log(referenceFragment.children);
// output: HTMLCollection[div, span]
let parentElement = document.getElementById('body');
parentElement.appendChild(referenceFragment);
console.log(referenceFragment.parentNode);
// output: body
console.log(referenceFragment.children);
// output: HTMLCollection[div, span]
5. Custom Control-Flow Registry
Finally, we provide a way for users to register a custom or compound control flow. The Registry offers an API similar to CustomElementRegistry. An example is shown below:
class IterationControlFlow extends ControlFlow {
// Implementation of the control flow class API
// ...
}
customControlFlow.define("iteration", IterationControlFlow);
Bringing It All Together
If you've followed along this far, you might be wondering how these ideas come together and where I'm going with this. I'm proposing a JavaScript library that can handle most of what modern web frameworks do for UI development, while maintaining a distinct separation between describing UI components in HTML and rendering them through JavaScript utilizing the Web Component lifecycle. I will publish a demo for you to experiment with in a follow-up article.
Until then, if these concepts intrigue you or resonate with challenges you've faced in your web development experience, I invite you to share your insights and feedback in the comments below. Additionally, if you have any questions or are interested in collaborating further, don't hesitate to reach out. I look forward to hearing from you.