The Web is Broken. Let's Fix It!
For a bit of context, I am a full stack developer with experience in JVM based languages (think of Java or Kotlin) on the backend and mostly in React on the frontend.
So here is the problem - I can’t help but notice that for the past 15 years I was involved in the JS world, we went from one crisis to another. Each new iteration or idea came packed with two big promises - faster speeds [I feel the need, the need for speed] and a better developer experience [developers, developers developers].
However, as you’ll see in the next few minutes, this was rarely the case. You don’t believe me? Here are 7 of the ideas the entire JavaScript ecosystem was thrilled about at first, but turned out to be… problematic in recent years.
- First we thought it was wise to move rendering and business logic from the server to the browser, and the SPA was born.
- Then, we figured out that the DOM can’t handle such heavy work loads so we created abstractions on top of it.
- My abstraction is better than your abstraction so here 17 new frameworks you can choose from to render some data on the screen.
- By the way, all these frameworks come with their own config processes, complex tools and novel ideas. Please, enjoy some JavaScript fatigue. We all have it!
- And, managing state is hard, so please model your apps around it. [A few moments later] Never mind, state is easy so just use one of the built in mechanisms.
- Wops! It turns out SPAs are increasingly bigger and it takes longer for browsers to download all the JavaScript and display some meaningful content for the users. Here is an original idea! Maybe we should move some of the rendering ON THE SERVER. Enter hydration - the solution you didn’t know you needed for the problem you didn’t know you had.
- Well, actually hydration feels like an afterthought so here are some better alternatives.
Modern Application Architecture
Of course, nowadays all frontend frameworks are running back towards the server, and there is a new concept we all have to be thrilled about - the meta frameworks. However, is the current path good enough, or are we just constantly fighting fires in an attempt to fix a problem we caused in the first place.
Before looking at what I believe to be a way better alternative, let’s briefly review the architecture of a modern app, and compare it with what we had 15 years ago.
So, before the whole JavaScript craze, apps were pretty straight forward. You had a server, or a cluster of servers running something like Symphony, Ruby on Rails or Spring. The server was in charge of fetching data from a database, performing business logic, rendering the html and sending it to the browser. Of course, browsers could still run JS scripts to enhance the user experience and perform small tasks directly on the frontend.
Ok, now let’s look at what modern web app architectures are proposing. You still have your browser and your backend server, but a new Node JS component is added to the mix with the sole purpose of doing rendering on the server. So the client running something like NextJS makes a request to the server. The request reaches a Node instance where the HTML is computed. Most of the time this Node server will perform additional requests to your actual backend server where all the data and business logic is stored. The response is then used by Node to compute the HTML which is finally sent to the browser.
So you have option A following the KISS principle, and option B requiring more resources due to the increased number of servers, and depending more on latency because these servers still have to communicate with each other.
So option B is option just like option A but with an additional extra step and more headaches, all for free!
Sane people will probably say that aiming for simplicity is the reasonable choice, and, luckily for us, some framework authors made efforts towards removing abstractions and simplifying the dev process in the past few years.
Solid JS really deserves a shout out here, and, at this point I believe it to be the best option when you really have to build SPAs.
A New Approach
However, this article is about a completely different approach. Reviewing all the past mistakes and lessons learned, a new class of libraries are emerging. I am talking, of course, about the likes of HTMX and Alpine which are fully relying on the server to perform the business logic and rendering, while adding interactivity on the client in a straightforward manner, only when it is needed. With very little effort, these libraries are able to check all the fancy boxes established frameworks are struggling to reach. They are HTML first, they rely on zero JavaScript to display meaningful content to users, and they are performing amazing over the wire.
So, while building SPAs and employing various rendering strategies still apply to some scenarios, the majority of apps could be built in a much easier fashion thanks to libraries like HTMX and Alpine.
However, I’d go a step further and argue that you don’t need a library or framework at all. In the words of Douglas Crockford, it is time to abandon the libraries which have grown into bloated platforms, and instead use the DOM and plain old JavaScript together.
If you don’t think this is achievable - you’ll be surprised. Let’s use all the lessons learned from the past 15 years, draw inspiration from HTMX and Alpine, and write a minimalistic JS internal library which can potentially power a full web app thanks to a few really good yet simple ideas.
So I want this JS library to allow me to:
-
Follow a basic, simple architecture where the server handles most of the heavy lifting.
-
Build interactive apps with great user experience;
-
Avoid writing unnecessary JavaScript. Even better, write no JavaScript at all;
-
Use async calls when communicating with the server to avoid unnecessary page refreshes;
-
Be flexible and extendable;
As a bonus, let’s keep this in under 200 lines of code to justify building it internally.
Whenever you end up writing thousands of lines of custom code, you might be better off just using an existing library.Interactivity is key here, so let’s build a messaging app.
In a perfect world, I would receive this basic HTML from the server, which will then be annotated with some special directives.
Using x-bind I want to be able to link DOM elements to reactive data. In other words, I want to seamlessly maintain some state on the frontend, and whenever that state changes updates will be triggered in the DOM. Of course, we’ll use signals to achieve this.
<main>
<div x-bind:class="'chat' + (open.value ? 'open' : 'close')">
<header>
<h3>Awesome Chat</h3>
<button>Open</button>
</header>
<main id="messages" x-html="/messages" x-pool="1s"></main>
<footer>
<input
name="message"
x-on:enter="post:/messages"
x-to:append="#messages"
/>
</footer>
</div>
</main>
In our example the chat bubble will receive an open or close class based on the open signal value.
Then, using x-on, and an event specifier I should be able to modify the value of any signal defined in the current scope.
We should also be able to use x-on to trigger async requests to the server directly. This is inspired by HTMX’s amazing idea that any DOM element should be able to trigger HTTP requests, just like links trigger GET requests and forms trigger POST requests.
In our example whenever the enter key is pressed, the input will fire an async post to the server’s /messages endpoint. A couple of additional notes here. First, we want to have access to special key up events like pressing Enter, since this is a pretty common scenario in modern apps. Second, the library should be smart enough to understand when server calls are triggered by input events, and send the input value as a payload. Finally, when the server returns the response to the browser, the html will be appended in the messages list.
I also want the messages to be populated in an async manner when the page is loaded in the browser, and, ofcourse, we should check for new messages from time to time.
x-html="/messages" x-pool="1s"
Finally, we have to load our library, and initialize it by passing the list of signals we want defined in the scope.
<main>
<div x-bind:class="'chat' + (open.value ? 'open' : 'close')">
<header>
<h3>Awesome Chat</h3>
<button>Open</button>
</header>
<main id="messages" x-html="/messages" x-pool="1s"></main>
<footer>
<input
name="message"
x-on:enter="post:/messages"
x-to:append="#messages"
/>
</footer>
</div>
<script src="/static/index.js"></script>
<script defer>
App.init({
open: true,
});
</script>
</main>
Now, for the fun part, let’s look at the implementation.
const KEYUP_EVENTS = ["enter", "esc"];
function init(data) {
[...document.querySelectorAll("*")]
.map((it) => {
const attr = [...it.attributes].find((attr) =>
attr.name.startsWith("x-on"),
);
if (!attr) return "";
return attr.name.indexOf(":") > -1 ? attr.name.split(":")[1] : attr.name;
})
.filter((it) => !!it)
.forEach((it) => {
if (KEYUP_EVENTS.indexOf(it) === -1) {
document.body.addEventListener(it, listener);
} else {
document.body.addEventListener("keyup", (ev) => {
if (ev.code.toLowerCase() === it) listener(ev);
});
}
});
}
In the init function, we’ll first go through the entire DOM to extract elements with special behavior. The x-on directive is triggering all our interactivity. This could be either updating the local signals state, or the server state via fetch calls. Remember that the directive has an event specifier, which can be either a DOM event or a special key event. I am registering listeners for all these events directly on the document body so that any new html that is inserted in the DOM later can trigger these listeners.
Next, let’s look for elements marked with the x-html directive, and populate the DOM with the result of the fetch call. We want more flexibility for this use case, and the x-pool directive allows us to perform the get request indefinitely. For convenience reasons, pool can receive both seconds and minutes as the attribute value.
const KEYUP_EVENTS = ["enter", "esc"];
function init(data) {
[...document.querySelectorAll("*")] {...}
[...document.querySelectorAll("[x-html]")]
.forEach(it => {
async function get() {
const resp = await fetch(it.getAttribute("x-html"));
it.innerHTML = await resp.text();
}
get();
let pool = it.getAttribute("x-pool");
if (pool) {
let raw = parseFloat(pool);
pool = pool.indexOf("m") > -1 ? raw * 60 : raw;
setInterval(get, pool * 1000);
}
});
}
Finally, let’s add the client state using a basic Signals implementation.
Our App exposes a special state property, which is a JS proxy allowing us to do some internal work and silent registering when working with signals and dependencies. Quick side note - signals are a pretty powerful construct, and they are powering most of the frontend frameworks these days.
const KEYUP_EVENTS = ["enter", "esc"];
function init(data) {
{...}
const signals = {};
Object.entries(data).forEach(([key, value]) => {
signals[key] = new Signal(value);
});
App.$state = new Proxy(signals, {
get(target, key) {
if (typeof key === "symbol") return;
const signal = target[key];
if (silentRegisterCaller) {
signal.dependencies.push(silentRegisterCaller);
}
return signal;
}
});
checkBindings(document.body);
}
Ok, next we have to check the bindings, and make sure that the DOM elements linked to our signals will be properly maintained.
function checkBindings(parent) {
const elements = [...parent.querySelectorAll("*")].filter((it) =>
[...it.attributes].find((attr) => attr.name.startsWith("x-bind")),
);
elements.forEach((it) => {
const key = it.getAttributeNames().find((it) => it.startsWith("x-bind"));
const code = it.getAttribute(key);
const extractor = new Function(`with(arguments[0]) { return ${code} }`);
silentRegisterCaller = function () {
if (key.indexOf(":") > -1) {
const attr = key.split(":")[1];
it.setAttribute(attr, extractor(App.$state));
} else {
it.innerHTML = extractor(App.$state);
}
};
silentRegisterCaller();
silentRegisterCaller = null;
});
}
Again, we are navigating the contents of a specific parent element, and extracting the attributes starting with the “x-bind” string. The value of the attribute is some JavaScript code we hve to execute in a context that has access to our $state scope. So let’s create a new function, wrap the attribute code into a with block, and then execute the function with the App state passed as the first argument. Remember that x-bind supports a specifier defining the bound attribute. If no specifier is present, we’ll default to simply updating the element content.
This simple signal / binding mechanism should cover most reactivity use cases on the frontend, and Van JS deserves credit for showing me that basic reactivity can be achieved in a clean, simple manner.
Now let’s get back to the x-on directive, which triggers all our interactivity. This directive accepts JavaScript code, which updates the client state via signals, and http verbs and endpoints which update the server state via fetch calls.
async function listener(ev) {
if (!isTargetElement(ev)) return;
const cfg = extract(ev);
if (cfg.code) {
const func = new Function(`with(arguments[0]) { return ${cfg.code} }`);
func(App.$state);
} else {
if (!cfg.confirm) {
call(ev.target, cfg);
} else if (cfg.confirm) {
if (confirm(cfg.confirm ?? "Are you sure")) {
call(ev.target, cfg);
}
}
}
}
Remember that we registered event listeners directly on the document body. These listeners will be called quite a lot, so we need to filter out the elements that don’t have any x- directives. Then, for each special element, we are analyzing its directive configuration. The extract function is pretty self explanatory, and you can analyze it in detail in this github repo.
When code is passed in as the value, we simply execute that code in the correct scope. When an URL is passed in, we’ll perform a call to the server.
The call implementation is straightforward. We are extracting the payload from the DOM element triggering the event, firing the fetch call, and inserting the response in the appropriate place based on the x-to directive.
Please note that this is a rather naive implementation, and you’d probably need to write a few more lines to implement some proper error handling and signal cleanup tasks. Let me know in the comments if you guys are interested in exploring this further, and maybe we’ll turn this into a proper framework. If you can’t beat them, join them, am I right?
Until next time, thank you for reading!