Hajime, the duck guy

Monday, July 22, 2024, by Hajime Yamasaki Vukelic

Vanilla questions

I generally get two kinds of questions about vanilla. The bad kind and good kind. The bad kind is asked by people who want to view my arguments through the lens of their own mental model, usually in order to find holes in them and shoot them down. The good kind is asked by people who are trying to map their mental model over to a different way of working.

In this post, I'm going to talk about a few of those good ones that I usually get from people genuinely interested in trying vanilla. If you wanted to ask me some good questions, maybe some of them are already answers here. Also, this post is not about dipping toes. It's about going all-in.

This is by no means a FAQ, since questions about vanilla are anything but frequent.

You mean I can do the old way?

This is usually a comment made by backend developers who know how to do frontend in some form, but not in the form done by the modern frontend "engineers". Well, for them, I've got some good news.

The "old way", which is the traditional server-side rendering (a.k.a., dynamic pages) is (still) well supported by many backend frameworks, and (still) works perfectly well today. In fact it works even better today. That's the superpower you get from sticking to the standards, if you ask me.

Web components or Astro?

First Astro. It's most certainly not vanilla, but there are some useful concepts it brought to the table.

I don't have any experience with Astro whatsoever, so take this with a bag of salt. The gist of Astro is the islands architecture. This appears to be its main selling point. The idea is that you load code for parts of the UI as they are "used". Now depending on how you define "used", it could be "visible in the viewport" or "interacted with", or something else. This can be done using vanilla JavaScript in around 300 bytes of code with something like this:

// Maps custom elements to /component/custom-element-name.js
let io = new IntersectionObserver(es => {
  for (let e of es) if (e.isIntersecting) {
    io.unobserve(e.target)
    import(e.target.dataset.module || `/components/${e.target.tagName.toLowerCase()}.js`)
  }
})
for (let $ of document.querySelectorAll('*')) 
  if ($.tagName.includes('-')) 
    io.observe($)

Although the islands architecture is a cool idea, I think it ultimately solves a problem you should not be having to begin with. I'll talk about this later, though.

Web components are part of the web standard, but I don't really like some parts. I love custom elements. They make things objectively better. Shadow DOM is not so great. They make some of my common tasks objectively worse. For example, because each shadow root creates a completely separate context, I cannot have shared styles with the host page, or even element ids visible outside the shadow DOM. It breaks a lot of the intuition I have about the DOM, which makes me less productive, particularly when it comes to accessibility. I prefer to use custom elements to wrap the normal DOM (a.k.a. light DOM).

You technically don't need these to do vanilla. However, vanilla is all about exploring what's already in the browser, so you will inevitably run into web components.

How do you build?

I don't. My code is shipped as is. When I hit "save" in my IDE, I know that that's what's getting shipped. I also know that that's what I'm getting in the browser. I'm also getting in my IDE the same thing I see in my dev tools and in my tracebacks. It's a beautiful integrated experience. Highly recommended.

But what if I have lots of code?

Consider this. I have a SVG spritesheet app that needs approximately 24KB of code. That's unminified JavaScript. Over a brotli-compressed connection, it comes down to about 6KB. On the other hand, minified version of VueJS is about 100KB. That's roughly four SVG spritesheet apps in a single piece of library.

When people say "lots of code", it's usually (though not always) a larger application that spans multiple contexts, not a tiny app like the SVG Spirit. Here, the context is a group of activities that you could possibly perform in a single setting.

For instance, in email clients, you have the following contexts:

  • reading messages
  • composing a new message
  • reply to a message
  • managing contacts
  • modify settings/preferences

You don't do all of those at the same time. Even if you technically could do all of those things at the same time, the utility of doing is is quite limited (my opinion).

While full page loads can be jarring, they are perfectly fine if you're doing them between context switches. Usually when users are switching context completely, they should not be required to remember what was in the previous one (or at least not all of it), so it's ok that they don't have immediate access to that information. For example, if you're switching to a "reply to email" context, you don't need to drag the whole inbox into it. You just need the message (or thread) to which the user is replying.

If you think of a single context as a mini-app within the app, then the whole thing about a mini app needing only 24KB of code starts to make sense. That's a useful metric even for larger apps. You probably need between 20KB and 60KB per context on average, not counting some complex libraries like WYSIWYG editors. If I need a separate context, I can add another such script on a different page. There's no need to bundle. In other words, split your app into a MPA – multi-page app.

Earlier when I said "solving a problem you should not be having to begin with", this is what I meant. It's very likely that you are having too much stuff going on in the same context.

I'm pretty sure a lot of people are going to raise their eyebrows at this section. That's fine. This is just my opinion, albeit based on having worked on this type of app, and having observed users reacting (actually, not reacting) to page reloads.

But what about shared scripts/styles?

Sharing scripts and styles actually improves performance if the users are expected to perform the context switches multiple times throughout the session as it takes advantage of the browser's caching mechanisms. There's nothing controversial about it, and most build tools do this.

For no-build situation, you can use ES modules in your browser. It even works with CDNs and 3rd party servers, as long as they are properly rigged to send appropriate CORS headers.

For instance, I load my Echo library directly from the server like this:

import {reactive, watch} as echo from 'https://stuff.yamasakivukelic.com/echo/versions/echo-v2.1.0.js'

What about micro-frontends?

For me, MPA is the micro-frontend.

First of all, micro-anything is mostly an organization issue, not a technical issue. You're wanting to divide the project into smaller bits that can be managed effectively by different groups of people. It doesn't solve for things like performance, simplification of deployment process, etc. In fact, it trades those off for organization of work.

My opinion (again, I said "opinion") is that, if you're not able to entrust an entire context a single group of people (or an individual even), you probably have more things in that context than is actually useful to the end user. So all of this combined, leads me to a conclusion that MPA is an optimal strategy if you want to split code in such a way that multiple groups can make progress in parallel. As a bonus, it also works for a single developer/team which also makes it quite flexible.

Of course, there are exceptions. For example, a CAD app or something like Figma can have quite dense contexts. YMMV based on what you're building.

How do you split the code into many modules?

I don't want to split my project into many modules. I split modules out when I need to share things between pages. Otherwise I neatly organize things within the same file. Most IDEs will support navigating the code by code structure, and I frankly don't see much difference between that and navigating the filesystem.

If you still want to split things out into physical files, then you'd use a build tool to create bundles. Not having a build step is not a requirement for vanilla. I recommend using ESBuild and recommend just sticking to what ESBuild is good at (don't ask it to do weird things like bundling icons into a font file). It's fast, simple, and gets the job done in most typical scenarios.

If you don't care that much about build speed and would rather have an easier time building, and you're building a traditional client-side app (not SSR), then I recommend ParcelJS. What's great about ParcelJS is it supports using HTML files as entry points, and it supports multi-page apps. It's also practically zero-configuration.

What if I have some library on NPM that I can't find on a CDN?

I've had one such case at one of my previous jobs. To handle this, I created a separate repo in which I had a single module that imports the dependency in question, and builds it into whatever I need for the project using ESBuild. I would then put the output of the build on my own CDN.

Do you use jQuery?

This is usually a question about whether there are libraries that I consider "vanilla" or I only consider it vanilla if I'm directly using the DOM API.

My definition of vanilla does, indeed, include jQuery. When I say "vanilla", I really mean direct DOM manipulation, rather than through a declarative layer over it. jQuery is just a library that makes direct DOM manipulation easier. All DOM operations have a direct counterpart in jQuery API so I retain full control over what my code is doing.

Do I use jQuery? No. However, jQuery is still as good as when it was released, and there are vanilla programmers using it. It's fluent interface made such an impact on the JavaScript community that some of it made it into the language spec (e.g., array methods). It also makes all DOM operations optional for empty selections, which is, in my opinion, more in line with the spirit of the language. It is quite extensible, and, if you like, there's a huge collection of plugins, many of them still quite usable. There's something about extensibility that modern frameworks could learn from jQuery.

What do you use for fetching data from the API?

I just use Fetch API, occasionally flirting with XMLHttpRequest API as well. The reason I don't use libraries like Axios is they give me very little value over what's already in the browser.

Each feature in such libraries I can replicate with just a little bit of extra code. Some features are actually better implemented in an entirely different layer (e.g., service workers are great for caching). While it's true that the libraries have many features, I only need some of them some of the time. I don't actually spend my time replicating all of Axios in my codebase. In fact, in 99% of cases all I need is the good ol' fetch() with no additional bells and whistles.

How do you do state management? (Part 1)

If I want to persist information between sessions I just use localStorage.

An example given to me is storage of the user profile information. This is a good candidate for localStorage.

Now, if I'm writing a local-first app, where the bulk of the activity takes place on the user's machine, with optional server connections, and the app deals with lots of user-generated data, then I might need something more substantial for storage. I have a choice of IndexedDB and filesystem API for that purpose.

If I want to persist information so that multiple tabs have access to the same state but I don't want to persist such information across sessions, I just use sessionStorage.

How do you do state management? (Part 2)

The second part of this question concerns the runtime application state. I've tried so many ways of doing state management I probably don't even remember all of them. A short list of methods that I find useful:

  • Keep state in the DOM (e.g., attributes, text content, etc.)
  • Use module- or class-level variables that are visible to every function in the scope
  • Use a lightweight reactive programming library

I find that each approach has cases where it shines, so my recommendation is to learn all of them, and use all of them to complement each other.

As far as reactive programming libraries go, you've got behemoths like MobX, or you could use Reactive stream libraries like RxJS or XStream. These are all framework-agnostic. I currently use my own lightweight library called Echo.

How do you create component libraries?

I don't. Whenever I decide I want to make a "component library", I'm basically locking myself into a frame of mind. Now I want everything to be part of this component library. In reality, that isn't very helpful nor necessary.

Unless... you want software development to look more like assembly line production. I don't think I've seen this work anywhere.

99% of the time, my end goal is not a component library, but a consistent look and feel. This is already possible with just CSS. Where I want to go with component libraries is usually more along the lines of making it easier to write and maintain something that's closer to a template. So that's what I do instead – templates.

My templates come in two flavors: a documented snippet of HTML that can be copy/pasted (or turned into snippets / live templates in my IDE so I can input them with just a few keystrokes) or proper templates in code. For the latter, I normally use the <template> tag or template literals.

But aren't small components better?

That's actually already accomplished by the HTML. My small components are HTML elements. If I need something more substantial, then I want it to be a significant value add over the plain elements.

For instance, a datepicker, searchable select list, combobox with auto-complete, etc. Those are worthwhile abstractions, and they are usually a few hundred lines long each. I reserve the word "component" for such things.

What about testing?

For me the only kind of test worth keeping long-term is BDD-style one. But I don't actually BDD. I write tests afterwards. When I say "BDD-style" I mean I test the user-facing behavior.

For UI code (code that involves the DOM) I use Playwright.

Unit testing doesn't really work for me when it comes to UI code. I leverage CSS a lot, and unit test tools for front end don't really cover that. Sometimes a piece of UI may break because of CSS and not because of JS, and then unit tests are worthless. For example, I may rely on setting pointer-events to none, and that actually changes the behavior of the UI. Unit testing tools cannot test some aspect of the UI even when they are based around JavaScript. For example, file uploads, canvas, drag & drop. For this reason, my conclusion is that Playwright are Selenium are the only worthwhile tools on the market right now.

But Playwright is not exactly a speed champ. Therefore, I don't test every user story under the sun. I only tests things that are tricky/complex enough that there could be some failure. It's a judgement call.

Not all code is UI code, though. However, because of the no-build setup, it's not exactly easy to poke at a random function within the script. This is why I've adopted sandbox development. For that I use Quokka, real-time sandbox with code coverage and time-travel debugger. I develop code in this sandbox along with some tests, and then I copy only the production code back into the script. One side-effect of this approach is that the code is virtually guaranteed to be properly isolated, so there's less coupling with the rest of the codebase. I don't do this for literally everything, though. It's not that convenient. I do it where it counts.

Posted in Programming tips
Back to top