Hajime, the duck guy

Monday, June 10, 2024, by Hajime Yamasaki Vukelic

To vanilla or not to vanilla?

Sometimes people tell me something along these lines:

Ok, I get it. Vanilla is faster, simpler, requires less code, and all that. But we have this huge pile of (insert framework name) code already. We can't just scrap all of it, can we?

Of course not. You shouldn't. In this post, I'll outline several strategies for introducing vanilla code into your project without scrapping everything you've already done. You can start as simple as possible, and gradually increase the portion of vanilla code over time (if you so wish).

No vanilla? No way.

First thing first. You cannot write apps without vanilla DOM code. Most modern frameworks only handle a small subset of all possible things you could do with the DOM. For everything else you are to write vanilla code scattered in hooks, directives, ports, tasks, and similar abstractions.

That is, unless you prefer to have a bag of dependencies that hide such code from you, and have the node_modules with thousands of packages.

added 29485 packages, and audited 29485 packages in 244s

Sounds familiar? Do you like depending on that many packages? I don't. Some say "But you shouldn't reinvent the wheels." I dunno, maybe I'm the only one allergic to square wheels and bumpy rides. 😂

It's... uh... different

First and foremost, vanilla approach is not the same as the framework approach. It has nothing to do with "reinventing frameworks" or whatever some may lead you to believe. If you catch yourself reinventing one, then you're probably better off just using a framework that works that way. Unless, of course, yours is better.

If you are used to frameworks, you're used to thinking in terms of state that drives DOM manipulation. In vanilla, DOM manipulation is directly driven by you, so you may or may not need separate values stored as "state" to begin with. Do not expect the view-as-a-function-of-state — V=f(S) — mental model to wear off as soon as you write your first lines of vanilla either. You've possibly spent years perfecting that approach and vanilla is completely different. I suggest you first get some practice in a sandbox.

Another reason you want to get some practice is that working with the DOM API doesn't mean just manipulating attributes on the elements and attaching and detaching event handlers. Elements can have their own special APIs that give you various features for free. You want to experience what built-in behaviors can do for you before you start doing it for real in production code. Here's a few examples of such APIs:

You will also want to get the hang of semantic markup as using the elements as intended gives you the best bang per buck — like, say, using a <button> rather than a <div> for something that you can click gives you keyboard and screen reader support for free. It also helps develop intuition about how elements work, which comes in handy when developing custom elements.

You will also want to learn how to leverage CSS and form a more holistic approach. I've also written about an analog clock widget that showcases some of these concepts.

Forms

When starting with vanilla DOM APIs, forms are a low-hanging fruit. Most apps have forms, so learning how to use the form-related API will give you the highest returns. In many cases, you will be able to get rid of a whole bunch of libraries with little effort.

For example, for normal forms — those that collect user data to be submitted to the server — in React (and similar frameworks), you don't need controlled components at all. You can use React as simply a template engine to render the form controls and optionally set their initial values using the defaultValue prop.

In the so-called "complex forms", I usually see two sources of complexity:

  • The libraries themselves
  • Complex widgets that leak their internal state into the application

The library-related complexity is not the essential complexity of the form. It's just a bad solution to form handling that is simply erased by using the DOM APIs directly.

The complexity coming from complex widgets that leak internal state can be dealt with using custom elements that make the widgets behave like built-in form controls, making them as easy to deal with as a regular text input or a select list. You can reasonably tuck state away into custom components but custom elements have bells and whistles that are not easy to replicate with framework-specific abstractions. To learn how to use custom elements in this context, take a look at the Custom confirmation code input kata.

You also don't need validation libraries. Validation can be handled using the native constraint validation API, including custom validation. Most people are familiar with attributes like required or type="email", but with a little JavaScript, you can add any kind of validation you need. You can also modify the presentation of the built-in validation popup by swapping it with your own elements and styling.

Collecting the form data for submission doesn't need any state, as the state is already managed by the browser. It can be done at submission time can be done either using the FormData() constructor, or accessing individual fields using the elements property on the form element:

function handleSubmit(ev) {
    // 1. No need for a ref. Just use event target.
    var form = ev.target 
    // 2. Capture the form data
    var formData = new FormData(form)
    // 3. Convert to an object 
    var data = Object.fromEntries(formData) 
    // 4. use the data object however you like
}

Other interactive elements

Dialogs are another common pattern where you can score some easy wins using the DOM API directly. The innocent-looking <dialog> element packs quite a punch when it comes to built-in behavior.

function handleOpen() {
        dialogRef.current.showModal()
}

function handleClose() {
        dialogRef.current.close()
}

By calling showModal() on a dialog element, you can bring up a modal dialog that has its backdrop element (actually a pseudo-element called ::backdrop), blocks access to the underlying content, keeps the Tab navigation within the dialog, and so on.

Modal dialog is not the only use for dialogs, though. A dialog can also be opened using a show() method, which opens a non-modal box. This is what you'd usually use for popups, drop-down menus, etc.

Less obvious examples of leveraging element behavior includes things like using radio buttons to create controls with mutually exclusive states (e.g., button groups, tabs), or checkboxes to implement controls that can be toggled (e.g., sidebars, toggle buttons, etc.). With some clever use of CSS, these controls require no state management to work. Once the :has() selector becomes a bit more widely supported, it will become even easier to leverage built-in behaviors.

Leverage event buses

Although the global state management (e.g., FLUX architecture) is quite common in modern frameworks, you can also leverage the browser's built-in event system to establish cross-component communication.

Most things in the DOM are event targets. This includes the global Window object. You can, therefore, use the global object as a global event bus for the entire app.

You may be wondering why a global event bus replaces global state. After all, state is not an event stream. To answer this, we first need to realize that global state is used to synchronize the states of different components in the application.

The state usually starts off living in one of the components until it needs to be shared with another one. When we need to share the state, we "lift" it to the first ancestor that is shared between the two components that require it. Then we either use prop-drilling or context to make this state available to child components. This has the disadvantage that the shared state is now managed by a component that doesn't really need it. Libraries like Redux and Vuex serve to address this by using a mechanism deceivingly similar to event buses... so why not just use an event bus to begin with?

Using the built-in event bus of the browser is cheap, because it's already there, and it's, by default, accessible to all components in the app. So it's two things less to worry about. The state doesn't need to be global at all, because we are not trying to abstract over "lifting" of the state. Instead, we want to prevent state-lifting by giving each component an ability to announce state changes to all other components. The components interested in such changes can then update their own state however they want. This decouples both the components as well as their state, resulting in simpler designs.

Let me show you an example of this:

var INITIAL_ITEMS_PER_PAGE = 10

function useGlobalEvent(eventName, callback) {
  useEffect(function () {
    window.addEventListener(`${eventName}`, callback)
    return window.removeEventListener(`${eventName}`, callback)
  }, [eventName, callback])
}

function List() {
  let [count, setCount] = useState(INITIAL_ITEMS_PER_PAGE)
  
  useGlobalEvent('x-setcount', function (ev) {
    setCount(ev.detail)
  })
  
  // Render the list
}

function Settings() {
  let [count, setCount] = useState(INITIAL_ITEMS_PER_PAGE)
  
  function setCount(ev) {
    if (!ev.target.checked) return
    var newCount = parseInt(ev.target.value, 10)
    setCount(newCount)
    window.dispatchEvent(new CustomEevent('x-setcount', {
      detail: newCount
    }))
  }
  
  // Render list settings
}

function App() {
  return (
    <section>
      <header>
        <Settings />
      </header>
      <div>
        <List />
      </div>
    </section>
  )
}

In the example above, the Settings and List components manage their states independently, and communicate with each other directly through the bus. The parent component, App, does not need to introduce them to each other, nor do they need to find each other in some other way. All that exists is a contract that they will talk to each other using the x-setcount event.

One less obvious thing about this pattern is that you can develop and test these components completely independently of each other because the bus does not make any assumptions about the presence of either the event listener or the event emitter.

When testing the component within the browser, you can even manually dispatch events on the Window to test the behavior, or attach your own listener to intercept the events. With the example above, you could execute code like this in the developer tools:

window.dispatchEvent(new CustomEvent('x-setcount', {detail: 20}))

or:

window.addEventListener('x-setcount', console.log)

Speaking of testing, you can also allow the bus to be injected via props if you want to set up a separate bus for each test. For example:

function Settings({initialCount, eventBus = window}) {
    let [count, setCount] = useState(initialCount)

    function setCount(ev) {
        if (!ev.target.checked) return
        var newCount = parseInt(ev.target.value, 10)
        setCount(newCount)
        eventBus.dispatchEvent(new CustomEevent('x-setcount', {
            detail: newCount
        }))
    }

    // Render list settings
}

With this small modification, you can now pass the event bus to the component during testing using the eventBus prop, and let it default to window in a production scenario. The event bus used in the tests can be created using the EventTarget constructor.

Mixing custom elements and framework code

In some scenario, it is useful to use custom elements to add behavior to the nodes that are created by the framework. Something like this:

function ThemeToggle({onChange, currentTheme}) {
    return (
        <x-button-group>
            <fieldset onChange={onChange}>
                <legend>Pick a theme</legend>
                <label>
                    <input type="radio" name="theme" value="l"
                           checked={currentTheme == 'l'}/>
                    Light
                </label>
                <label>
                    <input type="radio" name="theme" value="d"
                           checked={currentTheme == 'd'}/>
                    Dark
                </label>
                <label>
                    <input type="radio" name="theme" value="0"
                           checked={!currentTheme}/>
                    System
                </label>
            </fieldet>
        </x-button-group>
    )
}

When doing this you need to be careful not to manipulate the properties and nodes created by the framework in the custom element code, as that usually results in unpredictable or predictably bad outcomes. Other than that, the integration is pretty seamless.

The recommended approach is to fully leverage the framework to render and manage the elements while using the custom elements to manage behavior only (where such behavior is complex). In many instances, this means that form controls, if any, will need to be uncontrolled, to use the React parlance. This is especially good in frameworks that support native custom events (e.g., Vue) while it may have some slightly rough edges in frameworks that don't (e.g., React).

Custom elements are convenient because they are usually used for complex client-side behavior, and can therefore cleanly separate client-side code from the server-side code in a SSR scenario. This is because the custom element code is completely separate form the component code. In other words, you don't need any special treatment or boilerplate in the custom element code to support SSR.

Adding more substantial vanilla code

In some cases you might opt to add something a bit more substantial to your framework-based application. For instance, chess.com uses vanilla code for its chess board while the rest of the application still uses Vue (I think). Anther use case would be integrating a library with imperative APIs. Visualization and media players may also fall in this category.

A convenient way to embed more complex vanilla code into a framework-based code base is to use custom elements. In this case, we treat the custom element as separate scope — a black box — that contains a sub-application. The communication between the sub-application and the host application can be done in several ways, and combinations thereof:

  • methods defined on the custom element that the host can call
  • event bus
  • observable attributes on the custom elements

Although observable attributes are convenient, transferring larger amounts of data will be more efficient using the other two methods.

This approach can also be used if your intention is to gradually replace all framework code. You will start with a smaller (but still significant) chunk of functionality and then work your way towards the outside by converting outer components with an even larger sub-application around one or more sub-applications.

Posted in Opinion
Back to top