Using the platform

How far can framework-free, zero-build codebases take you in 2021?

I recently came across a series of articles by Daniel Kehoe where he introduces The Stackless Way, an optimistic take on web development that proposes we “use the platform” (modern features built into the language) instead of frameworks and build tools that keep getting replaced every few years.

It was good timing. While I'm a front-end developer at heart, I've rarely had the luxury of focusing on it full time. I've been dipping in and out of JavaScript, never fully caught up, always trying to navigate the ecosystem all over again each time a project came up. And framework fatigue is real!

So, instead of finally getting into Rollup to replace an ancient Browserify build on an old codebase (which could also really use that upgrade from Polymer to LitElement…), I decided to go “stackless”. I took a long-time idea for a motion design project and built it using nothing but features native to the browser: vanilla JS, ES6 modules and web components.

Working on a codebase with no dependencies has been a way of rediscovering exactly what I get for free in 2021, and what value I'm adding by bringing frameworks, transpilers and bundlers to the mix. I'd like to share what I learned (and what I needed to unlearn) in the process.

The project

The project itself, Schematics: A Love Story, is a collection of animated diagrams from a visual poetry book by the same name. The animations dotted throughout this write-up are examples from the project: SVG created programmatically with modular, vanilla JavaScript, encapsulated in a web component, sitting inside a framework-free codebase.

Figure 14: A line spiralling upwards along a the time axis in a 3-dimensional coordinate system.
A diagram reproduced from Julian Hibbard's Schematics: A Love Story, handcrafted with modern JavaScript.
See full collection

The Stackless Way

Daniel Kehoe is not alone in his push-back on the complexity of the modern web. Frank Chimero's Everything Easy is Hard Again gets to me every time, and, more recently, I enjoyed this lighthearted rant about the lack of appreciation for vanilla JS.

Appeals for external JS dependencies to be added with thoughtful intent have become common, but some schools of thought question the concept of single-page apps as a whole: server-side rendering is still a thing, after all. Pages can also be pre-built into a fully static site and served from a CDN (see Jamstack). These approaches recognise that we can move some of the complexity currently managed by front-end frameworks elsewhere on the stack.

But Kehoe's series of articles on going stackless feels a little different. It's not just about JavaScript — it's a wholehearted devotion to the native features of the web as a platform (Routing? Just make sure every URL matches a .html file!)

There are limits to this kind of purism, of course. The Stackless Way, to me, is less of a realistic approach to building production web apps and more of a learning and introspection tool, a way to take a step back and fall in love with the platform again.

Being all about motion design, my own project relied on many a setTimeout and page transitions — a single-page app, really… just handcrafted. The two main technologies that made it possible are ES6 modules and web components, which, along with module CDNs, form the pillars of The Stackless Way.

ES6 modules

If you've been developing for the web for some time, you might remember doing this:

<head>
  <script src="/js/vendor/jquery.min.js"></script>
  <script src="/js/main.js"></script>
  <script src="/js/subscribe.js"></script>
  <script src="/js/gallery.js"></script>
  <!-- Many more script tags whose specific ordering was a source of bugs and frustration -->
</head>

Listing all required scripts separately in the document <head> was all we could do given the lack of support for a native import/export mechanism in JavaScript. This was, of course, suboptimal:

  • each script tag initiates a HTTP request;
  • there is no namespacing (all scripts exist in the global scope);
  • the order of execution is linear, and maps directly onto the ordering of the script tags.

To combat this, approaches to modularity in JavaScript have proliferated over the years (AMD, UMD, CommonJS), and along with them, build tools and bundlers to convert the modular code into something that the browser understands.

ECMAScript Modules (also referred to as ESM or ES6 Modules) is the first native standard for modules in JavaScript. I emphasise native here because that means we can ditch the build tools and bundlers in favour of <script type="module"> in HTML and import/export in JS:

<head>
  <!-- Single entry point for a dependency graph of any depth -->
  <script type="module" src="main.js"></script>
</head>
// main.js
import Gallery from "./Gallery.js";
import SubscriptionForm from "./SubscriptionForm.js";

// Gallery.js
import Swipe from "./helpers/gestures.js";

export default class Gallery {}

Native support for modularity is the most important step towards a build-free codebase. If I had access to only one ES6 feature for the rest of my life, I'm confident that modules would take me most of the way there when it comes to well-structured native JavaScript.

And not having to build your JS app is magical. Setting up the directory structure for Schematics, I was giddy with excitement skipping npm run build and seeing my source files mirrored — as is! — in the browser. It reminded me of when I first began building websites and double-clicking index.html was enough to see your work in the browser.

You do need a local server if you're using ES6 modules (so double-clicking an HTML file might be a thing of the past for good). But there's no lag between editing your code and seeing changes in the browser, and the source code you see in the inspector is exactly what you typed into your editor (no sourcemaps!).

Nevermind the faster edit-compile-debug cycle — this speaks directly to Chimero's concern about the lack of legibility in today's codebases as an obstacle to learning the craft:

Before, the websites could explain themselves; now, someone needs to walk you through it. Illegibility comes from complexity without clarity. I believe that the legibility of the source is one of the most important properties of the web. […] the best way to help someone write markup is to make sure they can read markup.

Figure 18: A flow diagram for love. Good? Yes. More? Yes. Do it.
A diagram reproduced from Julian Hibbard's Schematics: A Love Story, handcrafted with modern JavaScript.
See full collection

I recommend reading Kehoe's Javascript import explained or Ayodeji's Introduction to ES6 modules for a historic overview of approaches to modularity, how we got to ESM, and how to write your own modules.

The future of app bundles

Just because you don't strictly need a build tool to run your code, doesn't mean you shouldn't use one for production builds to optimise performance. I'm referring here not to transpilation, but to minification, mangling, tree-shaking, etc.

I've been bundling JavaScript into a single app.js file for such a long time that my first instinct was to concatenate everything into a single script on Schematics, too. This, of course, will not work with ES6 modules — the concept of files is what creates boundaries between different modules, and there is no way to specify more than one module per file.

Luckily, you don't need to bundle your ES6 modules into a single file to improve performance. Let me repeat: you don't need to bundle your ES6 modules into a single file to improve performance.

Here's why:

  1. <script type="module"> requests are deferred by default: they won't block document parsing.
  2. Serving all modules from separate source files is great for caching, as the modules unaffected by some change can continue being retrieved from the cache. When you serve a single bundle, any one change will invalidate the whole bundle.
  3. You can lazy load parts of your code by making use of dynamic imports — importing modules during runtime at the point where you actually need them.
  4. You can preload critical modules with modulepreload. This starts parsing and compiling the linked modules immediately, off the main thread.
  5. Finally, HTTP2 may in the future allow us to resolve the entire dependency graph from the first <script type="module"> request, and send all required files back in a single response. For the time being, this requires bespoke logic written on the server side.

And don't forget about bundle size:

If you inspect the output code generated by most popular bundlers, you'll find a lot of boilerplate whose only purpose is to dynamically load other code and manage dependencies, but none of that would be needed if we just used modules with import and export statements!

I still have some work to do to go against my first instinct to bundle. It's not uncommon for a SPA to have hundreds of JavaScript files, and the prospect of serving them all separately seems counterintuitive. Even having read up on all the reasons why I don't need to bundle my modules, I occasionally hesitated before separating some logic into its own file to avoid the extra request.

Figure 20: An illustration of the propagation of sound waves across arrays of vertical lines.
A diagram reproduced from Julian Hibbard's Schematics: A Love Story, handcrafted with modern JavaScript.
See full collection

For more details and interesting discussion around ES6 module load performance and app bundles, see this thread on the ECMAScript Discussion Archives. The spec is not set in stone yet, and there are plenty of ideas floating around, some more daring than others (my personal favourite is the proposal to serve the entire module dependency graph as a .zip file).

There's plenty of ongoing module work happening in Chrome, though, so we're getting closer to giving bundlers their well-earned rest!

I also recommend reading Using Native JavaScript Modules in Production Today by Philip Walton. It explains how to serve optimised module files alongside a standard fallback single-file bundle for older browsers.

Finally, if you're looking for an alternative to webpack that supports native modules, check out Vite or Rollup.

Web components

Web Components is an umbrella term for several native technologies (Custom Elements, Shadow DOM and HTML templates) that let us bundle the markup and dynamic behaviour of an element into a reusable piece of code, on a high level no different to the React or Vue component. As of May 2021, All the Ways to Make a Web Component lists 55 variants of a hypothetical <my-counter> component for comparison across bundle size, coding style and performance. Native web components, of course, stand out because they have no external dependencies (and, consequently, an unmatched tiny bundle size).

Here are some of my own impressions of web components as compared to external frameworks when it comes to DX and architecture.

Shadow DOM and <template>

The shadow DOM, a way of keeping the internals of a component inaccessible to the main document, is a neat feature in theory, but a natural use case for it didn't really arise when working on Schematics. I've played around with the shadow DOM in the past as part of a Polymer (now LitElement) project, and I mostly remember it creating more problems than it solved, especially when it came to styling. I can see the shadow DOM being useful in cases where a component is reused across many sites and in unpredictable contexts (such as elements in a UI library); for your standard <site-specific-header-dropdown>, it doesn't add much value.

The <template> and <slot> elements (the latter only useful if you're using the shadow DOM) are presented as a means of adding hidden markup inside JS, but they feel clunky if you're used to something like JSX. Additionally, the <template> tag seems counter-inuitive if the aim is to create portable components that can be included in an app with a single import. When declared in the HTML, any JavaScript making use of the <template> makes assumptions about what's available to it in the main document. Indeed, at times the index.html in Schematics felt like a dumping ground for various <template>s, rather than a neat overview of the page structure.

HTML Imports was a proposal that might have allowed us to package JS and HTML together into a single bundle, making <template> the star of the show. Unfortunately, the feature never took off.

Another proposed feature is a means to add variables, simple statements and event handlers to nodes in a <template> tag. The gist of it is similar to what you'd currently see in front-end frameworks:

<template id="card">
  <card>
    <h2>{{title}}</h2>
    <div>{{description}}</div>
    <a href="/card/{{id}}">Read more</a>
    <button handler="onEdit">Edit</button>
  </card>
</temlate>

Being accustomed to built-in data-binding in frameworks, until the variables above are automatically updated, the feature feels half-baked. As it stands, it's difficult to gauge the full, declarative markup of a component in one place, as update logic is scattered throughout the class with query selectors, innerHTML, document fragments and the like.

If you'd like variable and event handler support in <template> now, GitHub, who use vanilla web components internally, provide a polyfill for the minimum viable bits of the proposal. As for data binding, Danny Moerkerke describes one approach to implementing it yourself: Data binding for Web Components in just a few lines of code.

Figure 36: A swinging pendulum.
A diagram reproduced from Julian Hibbard's Schematics: A Love Story, handcrafted with modern JavaScript.
See full collection

Extending native elements

The web components feature I was most excited about, and one that a third-party framework inherently cannot provide, is the ability to extend native HTML elements. This allows you to inherit any built-in behaviour for that element in your component, including accessibility-specific properties. Unfortunatley, Apple have decided not to support customizable elements in Safari.

This is disappointing. Using front-end frameworks, we often implement our own, more snazzy versions of native HTML elements. Input elements especially, such as dropdowns and radio buttons, are tricky to style using CSS and often end up reproduced with JS. But reproducing the required ARIA attributes, keyboard navigation and the like requires extra effort — something we would get for free with customizable elements.

Components all the way down?

While ES modules sparked excitement for the future, I was left underwhelmed by the prospect of native web components replacing traditional front-end frameworks. In hindsight, weighing up web components against JS frameworks was missing the point of the exercise. It's not about making more of your JavaScript vanilla; it's spotting opportunities to forego JavaScript in the first place.

Being a React user, attaching my App.js root component to a <div id="app"> root node and working down the tree, in smaller components, is how I'm used to thinking about SPAs. Though you can attach your component onto individual DOM nodes (and you could easily mix and match frameworks, too), the more standard approach is to let the framework drive the whole page.

This will quickly fall apart when using web components. At the level of the atomic UI element, they shine; for the business logic and user flows, you should defer to a different part of your stack. As for static content — why render it with JavaScript if raw markup does the job perfectly?

And so, I caught my mindset shifting from an app that's just one big component to not using components by default, breaking the link between component and framework in the process.

In the case of Schematics, I ended up with just a couple of main custom elements sitting neatly in the middle of native HTML tags. A simplified illustration of the idea:

<body>
  <header></header>
  <aside></aside>
  <main>
    <schematics-figure></schematics-figure>
    <schematics-figure-toolbar></schematics-figure-toolbar>
  </main>
  <footer></footer>
</body>

It seems obvious in hindsight that a component on a website should encapsulate the behaviour and appearance of some bit of UI. But, as this article nicely puts it, with the proliferation of JS frameworks, “we've landed over in the ditch on the other side of the road: putting all sorts of behaviour into the same JSX-denoted structure as Components”.

“React renders your UI and responds to events” was how it was introduced. Not: “React is a way to transform and execute all your logic in a hierarchy of declarative markup”, as it has devolved into in many cases.

Figure 43: A 3D cube erratically rotating and changing size.
A diagram reproduced from Julian Hibbard's Schematics: A Love Story, handcrafted with modern JavaScript.
See full collection

The above thoughts are mostly based on first impressions. If you're interested in a more detailed review of the state of the web components spec, I recommend this excellent article by Andrea Giammarchi which takes a more critical look of the feature in the context of the 30-year history of the web.

The future of stackless

Schematics was a simple project as far as architecture goes, and web components and ES6 modules took me most of the way there. I can't see myself building a complex app without a framework. Fully static sites, too, are simpler to build using a generator like Jekyll: something like markup reuse across pages is impossible when you only have the browser and text editor to work with (the humble <iframe> might disagree with me here…).

Technical limitations aside, an issue I became aware of during this exercise was a lack of standards in vanilla JavaScript. Because we're so used to the framework dictating how to structure our codebase, there aren't many established guidelines should you decide to handcraft your JavaScript. Never mind the high-level architecture — even the way you define a class, use static variables, or implement composition all have a range of approaches to them. Web components fill in the gap when it comes to the UI; perhaps if more developers dared to go framework-free (when appropriate!) discussions around best practices would be more productive.

Know the platform

Developing web apps without frameworks or build tools is not an end goal in and of itself. As Daniel Kehoe put it in the stackless newsletter:

I don't think we'll be talking about “stackless” in a few years. It's just going to be part of any web developer's professional bag of tricks.

I'd love to see that happen. I have a special disdain for beginner JavaScript tutorials that have you run create-react-app as the first step, and this exercise has only strengthened my conviction that every beginner programmer should get to grips with HTML, CSS and vanilla JS before delving into frameworks. Features native to the web are what all frameworks share, and knowing the platform makes for a stronger foundation in the face of change.


Postscriptum: Stackless stylesheets?

I'm so used to writing LESS or SCSS, or, more recently, setting up a set of PostCSS plugins, that not having a preprocessor didn't cross my mind at first.

Indeed, Kehoe encourages the use of CSS frameworks and/or preprocessors “until libraries of custom UI elements are more broadly available”.

But such a big part of the appeal of stackless for me was running all source code in the browser as is — having a preprocessing step for CSS would ruin that. So, in the spirit of the exercise I limited myself to vanilla CSS.

I persisted with this right up until the first media query came along, and things began to look nasty. Yes, modern CSS does get you 90% there; custom properties are a real workhorse (don't forget, no preprocessor can give you properties that update during runtime, or that you can set/get via JavaScript). But the lack of native support for nested selectors (along with the parent selector) I cannot live without, and that extra 10% makes a world's difference.

Stackless CSS isn't there yet. Use a preprocessor.

About Elise Hein

I’m a design system engineer at Griffin. Previously, I helped build products for healthcare research at Ctrl Group, worked on personal digital branding at MOO, and researched development practices at UCL for my master’s degree in HCI.

I live in Tallinn, Estonia, where I’m raising a preschooler and a toddler with @karlsutt.
Find me on GitHub and LinkedIn. Follow me on Twitter (but know that I’m still building up to my first Tweet). Email me at elise@elisehe.in.