A TeuxDeux clone in plain HTML, CSS and JavaScript (no build steps). It's fully animated and runs smoothly at 60 FPS with a total transfer size of 55 KB (unminified).
More importantly, it's a case study showing that vanilla web development is viable in terms of maintainability, and worthwhile in terms of user experience (50% less time to load and 95% less bandwidth in this case).
There's no custom framework invented here. Instead, the case study was designed to discover minimum viable patterns that are truly vanilla. The result is maintainable, albeit verbose and with considerable duplication.
If anything, the case study validates the value of build steps and frameworks, but also demonstrates that standard web technologies can be used effectively and there are only a few critical areas where a vanilla approach is clearly inferior.
While the first version of the case study has been published in 2020, it has received significant updates over time. Also, further work is being done with Vanilla Prime, a practical guide to (almost) vanilla web development based on insights from this case study.
Intermediate understanding of the web platform is required to follow through.
I believe too little has been invested in researching practical, scalable methods for building web applications without third party dependencies.
It's not enough to describe how to create DOM nodes or how to toggle a class without a framework. It's also rather harmful to write an article saying you don't need library X, and then proceed in describing how to roll your own untested, inferior version of X.
What's missing are thorough examples of complex web applications built only with standard web technologies, covering as many aspects of the development process as possible.
This case study is an attempt to fill this gap, at least a little bit, and inspire further research in the area.
The method for this case study is as follows:
This section describes the method in more detail.
I've chosen to build a (functionally reduced) clone of TeuxDeux for this study. The user interface has interesting challenges, in particular performant drag & drop when combined with animations.
The original TeuxDeux app deserves praise here. In my opinion it has the best over-all concept and UX of all the to-do apps out there. Thank you!
The user interface is arguably small (which is good for a case study) but large enough to require thought on its architecture.
However, it is lacking in some key areas:
To produce valid vanilla solutions, and because constraints spark creativity, I came up with a set of rules to follow throughout the process:
(1) This is a moving target; the current version is using ES2020.
(2) These usually end up becoming a custom micro-framework, thereby questioning why you didn't use one of the established and tested libraries/frameworks in the first place.
The results are going to be assessed by three major concerns:
The product should be comparable to or better than the original regarding functionality, performance and design.
This includes testing major browsers and devices.
The implementation should be maintainable and follow established code quality standards.
This will be difficult to assess objectively, as we will see later.
The discovered techniques and patterns should be applicable in a wide range of scenarios.
This section walks through the implementation, highlighting techniques and problems found during the process. You're encouraged to inspect the source code alongside this section.
Since build steps are ruled out, the codebase consists of plain HTML, CSS and JS files. The HTML and CSS follows rscss (devised by Rico Sta. Cruz) resulting in an intuitive, component-oriented structure.
The stylesheets are slightly verbose. CSS variables did help but I missed SCSS here; I think it's a must-have for bigger projects. Additionally, the global CSS namespace problem is unaddressed (see e.g. CSS Modules).
All JavaScript files are ES modules (import
/export
). I added a few
JSDoc
comments to functions to get additional code completion in VSCode. This helps,
but using TypeScript would be much safer and less verbose.
Note that I've opted out of web components completely. My attempts to refactor the implementation using web components either added more complexity, or did not show significant value over the initial, more basic approach.
The basic structure comes with some boilerplate, e.g. referencing all the individual stylesheets and scripts from the HTML; probably enough to justify a simple build step.
It is otherwise straight-forward—literally a bunch of HTML, CSS and JS files.
Naturally, the JavaScript architecture is the most interesting part of this study.
I found that using a combination of functions, query selectors and DOM events is sufficient to build a scalable, maintainable codebase, albeit with some trade-offs as we will see later.
Conceptually, the proposed architecture loosely maps CSS selectors to JS functions which are mounted (i.e. called) once per matching element. This simple mental model aligns well with the DOM and styles:
TodoList -> .todo-list
scripts/TodoList.js
styles/todo-list.css
AppCollapsible -> .app-collapsible
scripts/AppCollapsible.js
styles/app-collapsible.css
...
This proved to be a useful, repeatable pattern throughout all of the implementation process.
Mount functions take a DOM element as their first argument. Their responsibility is to set up initial state, event listeners, and provide behavior and rendering for the target element.
For example, this mount function implements a simple counter:
// Define mount function.
// Loosely mapped to ".my-counter".
export function MyCounter(el) {
// Define initial state.
let value = 0;
// Set rigid base HTML.
el.innerHTML = `
<span class="value"></span>
<button class="increment">Increment</button>
<button class="decrement">Decrement</button>
`;
// Attach event listeners.
el.querySelector('.increment').addEventListener('click', () => {
// Dispatch a custom event, using .detail to transport data.
// Parent components can listen to this event to receive the counter's value.
el.dispatchEvent(
new CustomEvent('counter', {
detail: value + 1,
bubbles: true,
}),
);
});
el.querySelector('.decrement').addEventListener('click', () => {
el.dispatchEvent(
new CustomEvent('counter', {
detail: value - 1,
bubbles: true,
}),
);
});
// This event handler supports the increment/decrement actions above,
// as well as resetting the counter from the outside.
el.addEventListener('counter', (e) => {
// Update state and re-render.
value = e.detail;
update();
});
// Define idempotent update function.
function update() {
el.querySelector('.value').innerText = value;
}
// Initial update.
update();
}
// Mount MyCounter component(s).
// Any <div class="my-counter"></div> in the document will be mounted.
document.querySelectorAll('.my-counter').forEach(MyCounter);
This comes with quite some boilerplate but has useful properties, as we will see in the following sections.
Note that a mount function does not have to set any base HTML, and may instead
only set event listeners to enable some behavior. Also note that an element can
be mounted with multiple mount functions. For example, to-do items are mounted
with TodoItem
and AppDraggable
.
Compared to React components, mount functions provide interesting flexibility as components and behaviors can be implemented using the same idiom and combined arbitrarily.
Reference:
I found it effective to implement one-way data flow similar to React's approach, however exclusively using custom DOM events.
The business logic is factored into a pure functional core (TodoLogic.js). This is a sensible approach in most UI architectures as it encapsulates state transitions in portable, testable units.
The controller is factored into a separate behavior (TodoController.js). It only receives and dispatches events, calling the business logic to apply changes and emit state. It also handles persistence in Local Storage.
Listening to and dispatching events is slightly verbose with standard APIs and certainly justifies introducing helpers. I didn't need event delegation à la jQuery for this study but I believe it's a useful concept that is difficult to do concisely with standard APIs.
Reference:
Naively re-rendering a whole component using .innerHTML
should be avoided as
this may hurt performance and will likely break important functionality like:
<a>
, <button>
, <input>
, etc. may lose focus.As seen in 3.2.1., rendering is therefore split into some rigid base HTML and an idempotent, complete update function which only makes necessary changes.
In effect, this means almost all DOM manipulation is done in update functions, which greatly contributes to robustness and readability of the codebase.
As seen above this approach is quite verbose and ugly compared to JSX, for example. However, it's very performant and can be further optimized by checking for data changes, caching selectors, etc. It is also simple to understand.
Reference:
Expectedly, the hardest part of the study was rendering a variable amount of dynamic components efficiently. Here's a commented example from the implementation outlining the reconciliation algorithm:
export function TodoList(el) {
let items = [];
el.innerHTML = `<div class="items"></div>`;
el.addEventListener('todoItems', (e) => {
items = e.detail;
update();
});
function update() {
const container = el.querySelector('.items');
// Mark current children for removal
const obsolete = new Set(container.children);
// Map current children by data-key
const childrenByKey = new Map();
obsolete.forEach((child) => childrenByKey.set(child.dataset.key, child));
// Build new list of child elements from data
const children = items.map((item) => {
// Find existing child by data-key
let child = childrenByKey.get(item.id);
if (child) {
// If child exists, keep it
obsolete.delete(child);
} else {
// Otherwise, create new child
child = document.createElement('div');
child.classList.add('todo-item');
// Set data-key
child.dataset.key = item.id;
// Mount component
TodoItem(child);
}
// Update child
child.dispatchEvent(new CustomEvent('todoItem', { detail: item }));
return child;
});
// Remove obsolete children
obsolete.forEach((child) => container.removeChild(child));
// (Re-)insert new list of children
children.forEach((child, index) => {
if (child !== container.children[index]) {
container.insertBefore(child, container.children[index]);
}
});
}
}
It's very verbose, with lots of opportunities to introduce bugs. Compared to a simple loop in JSX, this approach seems unreasonable. It is quite efficient as it does minimal work, but it's definitely a candidate for a utility function or library.
Implementing drag & drop from scratch was challenging, especially regarding browser/device consistency.
Using a library would have been a lot more cost-effective initially. However, having a customized implementation paid off once I started introducing animations as both had to be coordinated closely. I can imagine this would have been a difficult problem when using third party code for either.
The drag & drop implementation is (again) based on DOM events and integrates well with the remaining architecture. It's clearly the most complex part of the study but I was able to implement it without changing existing code besides mounting behaviors and adding event handlers.
I suspect the drag & drop implementation to have some subtle problems on touch devices, as I haven't extensively tested them. Using a library for identifying the gestures could be more sensible and would reduce costs in testing browsers and devices.
Reference:
For the final product I wanted smooth animations for most user interactions. This is a cross-cutting concern which was implemented using the FLIP technique as devised by Paul Lewis.
Implementing FLIP animations without a large refactoring was the biggest
challenge of this case study, especially in combination with drag & drop. After
days of work I was able to implement the algorithm in isolation and coordinate
it with other concerns at the application's root level. The useCapture
mode of
addEventListener
proved to be very useful in this case.
Reference:
While no runtime dependencies or build steps were allowed, I did introduce some local tooling to support the development experience.
As a quick start, here are the steps to get a local development server up and running:
npm install
npm run dev
The following sections describe the tooling in more detail.
Because ES modules are not allowed under the file://
protocol I needed to run
a local web server for development. Initially, I used
serve which was good enough to get going
but requires manually reloading the application on every change.
Most modern frameworks support hot reloading, i.e. updating the application in place when changing source files. Hot reloading provides fast feedback during development, especially useful for fine-tuning visuals.
Unfortunately, I could not find a local development server supporting some form of hot reloading without introducing a framework or build system, but I was able to implement a minimal local development server with the following behavior:
While it's not proper hot module replacement (which needs immense infrastructure), it requires zero changes to the application source and provides a similar experience because page reloads are fast.
Basic code consistency is provided by
I've set the ESLint parser to ES2020 to ensure only ES2020 code is allowed. I've also added stylelint rules to check for rscss-compatible CSS.
Run these commands to try it out:
npm run format-check
to check formattingnpm run format
to apply formattingnpm run lint
to lint JavaScriptnpm run lint-styles
to lint CSSThese tools only required minimal configuration to be effective. They also integrate well with VSCode so I've rarely had to run these manually.
I've implemented some end-to-end and unit tests using Playwright. While running a local web server (see above), you can run the tests with
npm run test
for headless tests, ornpm run test-ui
for interactive mode.These might ask you to install Playwright; just follow the instructions.
There's a lot more to explore here, but it's not much different from testing other frontend stacks. It's actually simpler as there was zero configuration and just one dependency.
Reference:
I was able to set up code coverage for unit and end-to-end tests via Playwright's code coverage feature and c8. This introduced another dependency and was slightly more involved to get right, e.g. mapping localhost URLs to file URLs.
Use npm run test-coverage
to run the tests and produce an LCOV test coverage
report in ./coverage
.
Note that the implementation is specific to the project structure, e.g.
/public
as web root and port 8080
are hard-coded.
Reference:
I've added a simple CI/CD pipeline via GitHub Actions. It runs linters and tests, and deploys to GitHub Pages on success. This was straight-forward and is orthogonal to the application code and other tooling.
Reference:
I've mostly used Chrome DevTools for debugging and the experience was fantastic. It feels incredibly immediate inspecting an application without third-party code or any kind of cruft (e.g. source maps).
Most important features from the original TeuxDeux application are implemented and usable:
Additionally, most interactions are smoothly animated at 60 frames per second. In particular, dragging and dropping gives proper visual feedback when elements are reordered.
The latter was an improvement over the original application when I started working on the case study in 2019. In the meantime, the TeuxDeux team released an update with a much better drag & drop experience. Great job!
One notable missing feature is Markdown support. It would be unreasonable to implement Markdown from scratch; this is a valid candidate for using an external library as it is entirely orthogonal to the remaining codebase.
The application has been tested on latest Chrome, Firefox, Safari, and Safari on iOS.
TODO Test more browsers and devices.
A fresh load of the original TeuxDeux application transfers around 1.2 MB and finishes loading at over 1000 ms, sometimes up to 2000ms (measured in 12/2023). Reloads finish at around 700 ms.
With a transferred size of around 55 KB, the vanilla application consistently loads in 300-500 ms—not minified and with each script, stylesheet and icon served as an individual file. Reloads finish at 100-200 ms; again, not optimized at all (with e.g. asset hashing/indefinite caching).
To be fair, my implementation misses quite a few features from the original. I suspect a fully equivalent clone to be well below 100 KB transfer, though.
While there is still optimization potential, the Lighthouse score is perfect.
Unfortunately, it is quite hard to find undisputed, objective measurements for code quality (besides trivialities like code style, linting, etc.). The only generally accepted assessment seems to be peer reviewal.
To have at least some degree of assessment of the code's quality, the following sections summarize relevant facts about the codebase and some opinionated statements based on my experience in the industry.
All source files (HTML, CSS and JS) combine to under 3000 lines of code, including comments and empty lines.
For comparison, prettifying the original TeuxDeux's minified JS assets yields 81602 LOC (12/2023).
To be fair, my implementation misses quite a few features from the original. I suspect a fully equivalent clone to be well below 10000 LOC, though.
el.querySelectorAll(':scope ...')
is somewhat default/expected and would
justify a helper.Eliminating verbosity through build steps and a minimal set of helpers would reduce the comparably low code size (see above) even further.
Besides the issues described above, I believe the codebase is well organized and there are clear paths for bugfixes and feature development. Since there's no third party code, bugs are easy to find and fix, and there are no dependency limitations to work around.
A certain degree of DOM API knowledge is required but I believe this should be a goal for any web developer.
Assessing the generality of the discovered techniques objectively is not really possible without production usage. From my experience, however, I can't imagine any scenario where mount functions, event-based data flow etc. are not applicable. The underlying principles power the established frameworks, after all:
render
function).An open question is if these patterns hold for library authors. Although not considered during the study, some observations can be made:
The result of this study is a working to-do application with decent UI/UX and most of the functionality of the original TeuxDeux app, built using only standard web technologies. It comes with better overall performance at a fraction of the code size and bandwidth.
The codebase seems manageable through a handful of simple concepts, although it is quite verbose and even messy in some areas. This could be mitigated by a small number of helper functions and simple build steps (e.g. SCSS and TypeScript).
The study's method helped discovering patterns and techniques that are at least on par with a framework-based approach for the given subject, without accidentally building a custom framework.
A notable exception to the latter is rendering variable numbers of elements in a concise way. I was unable to eliminate the verbosity involved in basic but efficient reconciliation. Further research is needed in this area, but for now this appears to be a valid candidate for a (possibly external) general-purpose utility.
When looking at the downsides, remember that all of the individual parts are self-contained, highly decoupled, portable, and congruent to the web platform. The implementation cannot "rust", by definition, as no dependencies can become out of date.
Another thought to be taken with a grain of salt: I believe frameworks make simple tasks even simpler, but hard tasks (e.g. implementing cross-cutting concerns or performance optimizations) often more difficult.
Setting some constraints up-front forced me to challenge my assumptions and preconceptions about vanilla web development. It was quite liberating to avoid general-purpose utilities and get things done with what's readily available.
While I think the study is relatively complete, there's always more to explore. Ideas, questions, bug reports and pull requests are more than welcome!
Finally, this case study does not question using dependencies, libraries or frameworks in general—code sharing is an essential part of software engineering. It was a constrained experiment designed to discover novel methods for vanilla web development and, hopefully, inspire innovation and further research in the area.
As detailed in the assessment, the result of the case study could be significantly improved if build steps and helpers were allowed. Beyond the strict rules I've used in this experiment, here are a few ideas I'd like to see explored in the future:
reconcile()
) to mitigate some of
the discovered downsides.
Case studies constrained by a set of formal rules are an effective way to find new patterns and techniques in a wide range of domains. I'd love to see similar experiments in the future
General resources I've used extensively:
Useful articles regarding FLIP animations:
Projects I've inspected for drag & drop architecture:
Useful VSCode extensions:
Thanks!
dataset
(#2)
— @opethrocks