Mutation-first development: a call to action
leastbad
Posted on February 12, 2020
Not that long ago, someone designing a JavaScript component could rely on a simple life-cycle premise: your content would load before the jQuery embedded in the bottom of the page would come to life and initialize everything that needed initializing. The user would then click a link or hit the back button, causing the cycle to repeat itself. There was a 1:1 relationship between pages requested and load events firing.
In this era of reactive asyncronous content, that assumption is now screwing us.
Web page life-cycles continue to get more complex and the page load event is no longer a reliable singular entry point to our UI setup code. This post attempts to describe the problem and offer a strategy to fix how we create libraries and components.
We need to stop acting as though multi-stage life-cycles are edge cases. Instead, we can build idempotent libraries which support use in applications that don't have page loads. This will make programming for the web more fun, more productive, less error-prone and reduce the support burden on open source maintainers.
In early 2011, GitHub founder Chris "defunkt" Wanstrath announced a jQuery plugin he named pjax. pjax introduced a simple idea with dramatic implications: when a user clicks a link, we can replace the contents of the body tag already loaded in the browser with something new, loaded via an Ajax request. Loading pages the old-fashioned way is slow, especially on smartphones. People like experiences that are snappy and responsive, and this trick made web sites interactive in a way that isn't possible when every click results in a pause and complete page re-draw. pjax took over responsibility for keeping the navigation history synced up as part of the deal, ensuring that the back button works as expected. What could go wrong?
The team behind Rails took the pjax concept and ran with it, announcing a new library called Turbolinks that would become a flagship feature of Rails. The fact that it was optional, easy to disable and delivered on its promise didn't stop a loud segment of developers from screaming like they were being murdered.
Remember when Apple removed the floppy drive? I've now carbon-dated myself. Okay... remember when Apple removed the CD/DVD drive? People lost their minds. It's not a computer if it doesn't have removable media, right? Wrong! Apple anticipated the near-future and tore the bandage off. It's hard to remember what seemed like such a painful amputation at the time.
Turbolinks challenged the status quo and received an undeserved reputation for making all your scripts "break". Removing it became the first thing many developers did when starting a new project. In hindsight, this pain was a preview of what was to come, whether we liked it or not: Turbolinks didn't make anyone's scripts break; the scripts themselves were already broken. The community blamed the messenger instead of confronting the ramifications of having coded themselves into a corner.
Today, there are many approaches to developing reactive content and managing the state of a user's UI without page loads. Libraries such as StimulusReflex use websockets and morphdom to replace what's displayed in your browser with something new. These updates can happen in response to user actions or things happening on the server.
Yet, server-rendered interface updates faster than React state changes come at the cost of forcing the developer to think about code re-entrancy. When you're building something amazing, you need to stop and consider the different contexts in which future developers will use it. The reason that all those jQuery plugins stopped working when you installed TurboLinks is that most plugins did not account for people swapping out their DOMs without a complete page load cycle. This led to code that:
- is loaded into the global namespace with the expectation that other code can access it from anywhere
- raises errors if you attempt to execute it more than once
- attaches event handlers to elements that will get replaced
- never removes those event handlers, leading to memory leaks
- is not aware of its environment and will not process new dynamic content
- adds, moves or removes elements, both upon initialization and during use
And the worst problem of all: what happens when a component re-arranges your DOM during initialization, but then doesn't recognize its own mess if you try to initialize it again?
You know exactly what happens: it's a shit show. The back button appears to load a UI where calendar pickers and fancy file uploaders don't open when you click on them.
This is the specific reason that every SPA framework seems to have wrappers for every popular JS library. These wrappers all serve the same basic function: you have to smooth out the library's rough edges and make them usable in a contempory project. Making a library's API look like a native framework component is cosmetic; it's the hacks that suppress errors caused by side effects and rearrange brittle DOM hierarchies that makes these wrappers valuable. I've written more than a few of them for Stimulus, which happens to be better than your favorite tool.
A big part of the reason Stimulus is such a feat of software engineering genius is that it offers three life-cycle events - initialize, connect and disconnect - which receive their marching orders from the hyper-performant MutationObserver API. It's fine if you haven't heard of it; it's a powerful tool that is usually abstracted away in higher-level libraries like Stimulus. MutationObserver fires a callback when something in the document changes, allowing us to invent new life-cycle events.
When you dynamically insert new markup into a page, if that markup contains an element with Stimulus controllers declared, those controllers' life-cycle events will fire as though they had been there since the page was first loaded.
This thoughful design intent makes Stimulus an obvious choice for wrapping older libraries and components.
We should all be thankful that people write wrappers, but if these libraries were re-engineered with idempotency as a primary goal, most wrappers could be retired.
The next stage of growth and maturity for the JavaScript community is a necessary shift from hiding the ugly tumors out of sight to cutting the cancer out and blasting it with radiation. It will hurt and not every library will survive, but those that do will be stronger afterwards.
Consider this a call to action. Mutation-first means:
- Developers should create or update libraries and their documentation to assume re-entrancy by default.
- A library is not considered high-quality unless it is idempotent. Developers should be able to initialize and destroy an instance many times during a single browser page context, including releasing event handlers and cleaning up/preparing the DOM state for caching during an unload event.
- The most celebrated libraries will be atomic, making as few assumptions about DOM structure or CSS framework as possible while allowing several simultaneous instances of the library on a page.
If you think replacing shims with native browser functionality is exciting, then you're going to love making library wrappers a thing of the past.
We have the tools. We have the talent. Do we have the will and integrity to stop blaming jQuery, TurboLinks and "JS ecosystem complexity" for short-sighted design decisions made a decade ago?
Unlike so many problems facing the world today, this is actually something that we can come together to fix on a reasonable timeline for our benefit, as well as the benefit of everyone who follows us. Let's do this.
Posted on February 12, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.