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.