What Web Frameworks Solve And How To Do Without Them (Part 1)
I have recently become very interested in comparing frameworks to vanilla JavaScript. It started after some frustration I had using React in some of my freelance projects, and with my recent, more intimate acquaintance with web standards as a specification editor.
I was interested to see what are the commonalities and differences between the frameworks, what the web platform has to offer as a leaner alternative, and whether it’s sufficient. My objective is not to bash frameworks, but rather to understand the costs and benefits, to determine whether an alternative exists, and to see whether we can learn from it, even if we do decide to use a framework.
In this first part, I will deep dive into a few technical features common across frameworks, and how some of the different frameworks implement them. I will also look at the cost of using those frameworks.
The Frameworks
I chose four frameworks to look at: React, being the dominant one today, and three newer contenders that claim to do things differently from React.
- React
“React makes it painless to create interactive UIs. Declarative views make your code more predictable and easier to debug.” - SolidJS
“Solid follows the same philosophy as React… It however has a completely different implementation that forgoes using a virtual DOM.” - Svelte
“Svelte is a radical new approach to building user interfaces… a compile step that happens when you build your app. Instead of using techniques like virtual DOM diffing, Svelte writes code that surgically updates the DOM when the state of your app changes.” - Lit
“Building on top of the Web Components standards, Lit adds just … reactivity, declarative templates, and a handful of thoughtful features.”
To summarize what the frameworks say about their differentiators:
- React makes building UIs easier with declarative views.
- SolidJS follows React’s philosophy but uses a different technique.
- Svelte uses a compile-time approach to UIs.
- Lit uses existing standards, with some added lightweight features.
What Frameworks Solve
The frameworks themselves mention the words declarative, reactivity, and virtual DOM. Let’s dive into what those mean.
Declarative Programming
Declarative programming is a paradigm in which logic is defined without specifying the control flow. We describe what the result needs to be, rather than what steps would take us there.
In the early days of declarative frameworks, circa 2010, DOM APIs were a lot more bare and verbose, and writing web applications with imperative JavaScript required a lot of boilerplate code. That’s when the concept of “model-view-viewmodel” (MVVM) became prevalent, with the then-groundbreaking Knockout and AngularJS frameworks, providing a JavaScript declarative layer that handled that complexity inside the library.
MVVM is not a widely used term today, and it’s somewhat of a variation of the older term “data-binding”.
Data Binding
Data binding is a declarative way to express how data is synchronized between a model and a user interface.
All of the popular UI frameworks provide some form of data-binding, and their tutorials start with a data-binding example.
Here is data-binding in JSX (SolidJS and React):
function HelloWorld() {
const name = "Solid or React";
return (
<div>Hello {name}!</div>
)
}
Data-binding in Lit:
class HelloWorld extends LitElement {
@property()
name = 'lit';
render() {
return html`<p>Hello ${this.name}!</p>`;
}
}
Data-binding in Svelte:
<script>
let name = 'world';
</script>
<h1>Hello {name}!</h1>
Reactivity
Reactivity is a declarative way to express the propagation of change.
When we have a way to declaratively express data-binding, we need an efficient way for the framework to propagate changes.
The React engine compares the result of rendering with the previous result, and it applies the difference to the DOM itself. This way of handling change propagation is called the virtual DOM.
In SolidJS, this is done more explicitly, with its store and built-in elements. For example, the Show
element would keep track of what has changed internally, instead of the virtual DOM.
In Svelte, the “reactive” code is generated. Svelte knows which events can cause a change, and it generates straightforward code that draws the line between the event and the DOM change.
In Lit, reactivity is accomplished using element properties, essentially relying on the built-in reactivity of HTML custom elements.
Logic
When a framework provides a declarative interface for data-binding, with its implementation of reactivity, it also needs to provide some way to express some of the logic that is traditionally written imperatively. The basic building blocks of logic are “if” and “for”, and all of the major frameworks provide some expression of these building blocks.
Conditionals
Apart from binding basic data such as numbers and string, every framework supplies a “conditional” primitive. In React, it looks like this:
const [hasError, setHasError] = useState(false);
return hasError ? <label>Message</label> : null;
…
setHasError(true);
SolidJS provides a built-in conditional component, Show
:
<Show when={state.error}>
<label>Message</label>
</Show>
Svelte provides the #if
directive:
{#if state.error}
<label>Message</label>
{/if}
In Lit, you’d use an explicit ternary operation in the render
function:
render() {
return this.error ? html`<label>Message</label>`: null;
}
Lists
The other common framework primitive is list-handling. Lists are a key part of UIs — list of contacts, notifications, etc. — and to work efficiently, they need to be reactive, not updating the whole list when one data item changes.
In React, list-handling looks like this:
contacts.map((contact, index) =>
<li key={index}>
{contact.name}
</li>)
React uses the special key
attribute to differentiate between list items, and it makes sure that the whole list doesn’t get replaced with every render.
In SolidJS, the for
and index
built-in elements are used:
<For each={state.contacts}>
{contact => <DIV>{contact.name}</DIV> }
</For>
Internally, SolidJS uses its own store in conjunction with for
and index
to decide which elements to update when items change. It’s more explicit than React, allowing us to avoid the complexity of the virtual DOM.
Svelte uses the each
directive, which gets transpiled based on its updaters:
{#each contacts as contact}
<div>{contact.name}</div>
{/each}
Lit supplies a repeat
function, which works similarly to React’s key
-based list mapping:
repeat(contacts, contact => contact.id,
(contact, index) => html`<div>${contact.name}</div>`
Component Model
One thing that is out of the scope of this article is the component model in the different frameworks and how it can be dealt with using custom HTML elements.
Note: This is a big subject, and I hope to cover it in a future article because this one would get too long. :)
The Cost
Frameworks provide declarative data-binding, control flow primitives (conditionals and lists), and a reactive mechanism to propagate changes.
They also provide other major things, such as a way to reuse components, but that’s a subject for a separate article.
Are frameworks useful? Yes. They give us all of these convenient features. But is that the right question to ask? Using a framework comes at a cost. Let’s see what those costs are.
Bundle Size
When looking at bundle size, I like looking at the minified non-Gzip’d size. That’s the size that is the most relevant to the CPU cost of JavaScript execution.
- ReactDOM is about 120 KB.
- SolidJS is about 18 KB.
- Lit is about 16 KB.
- Svelte is about 2 KB, but the size of the generated code varies.
It seems that today’s frameworks are doing a better job than React of keeping the bundle size small. The virtual DOM requires a lot of JavaScript.
Builds
Somehow we got used to “building” our web apps. It’s impossible to start a front-end project without setting up Node.js and a bundler such as Webpack, dealing with some recent configuration changes in the Babel-TypeScript starter pack, and all that jazz.
The more expressive and the smaller the bundle size of the framework, the bigger the burden of build tools and transpilation time.
Svelte claims that the virtual DOM is pure overhead. I agree, but perhaps “building” (as with Svelte and SolidJS) and custom client-side template engines (as with Lit) are also pure overhead, of a different kind?
Debugging
With building and transpilation come a different kind of cost.
The code we see when we use or debug the web app is totally different from what we wrote. We now rely on special debugging tools of varying quality to reverse engineer what happens on the website and to connect it with bugs in our own code.
In React, the call stack is never “yours” — React handles scheduling for you. This works great when there are no bugs. But try to identify the cause of infinite-loop re-renders and you’ll be in for a world of pain.
In Svelte, the bundle size of the library itself is small, but you’re going to ship and debug a whole bunch of cryptic generated code that is Svelte’s implementation of reactivity, customized to your app’s needs.
With Lit, it’s less about building, but to debug it effectively you have to understand its template engine. This might be the biggest reason why my sentiment towards frameworks is skeptical.
When you look for custom declarative solutions, you end up with more painful imperative debugging. The examples in this document use Typescript for API specification, but the code itself doesn’t require transpilation.
Upgrades
In this document, I’ve looked at four frameworks, but there are more frameworks than I can count (AngularJS, Ember.js, and Vue.js, to name a few). Can you count on the framework, its developers, its mindshare, and its ecosystem to work for you as it evolves?
One thing that is more frustrating than fixing your own bugs is having to find workarounds for framework bugs. And one thing that’s more frustrating than framework bugs are bugs that occur when you upgrade a framework to a new version without modifying your code.
True, this problem also exists in browsers, but when it occurs, it happens to everyone, and in most cases a fix or a published workaround is imminent. Also, most of the patterns in this document are based on mature web platform APIs; there is not always a need to go with the bleeding edge.
Summary
We dived a bit deeper into understanding the core problems frameworks try to solve and how they go about solving them, focusing on data-binding, reactivity, conditionals and lists. We also looked at the cost.
In Part 2, we will see how these problems can be addressed without using a framework at all, and what we can learn from it. Stay tuned!
Special thanks to the following individuals for technical reviews: Yehonatan Daniv, Tom Bigelajzen, Benjamin Greenbaum, Nick Ribal and Louis Lazaris.