Unplanned Obsolescence

Behavior Belongs in the HTML

December 11, 2023

When you click the button below, it's going to show you a little message.

Showing a pop-up when the user clicks a button isn't something the button supports on its own; you have to code it. There are two ways to attach custom functionality to an HTML element: inline, or using an event listener.

This is how you'd do it with an inline handler:

<button onclick="alert('I was clicked!')">Click me</button>

And this is how you'd do it with an event listener:

<button>Click me</button>

const btn = document.querySelector("button")

btn.addEventListener("click", () => {
  alert('I was clicked!')

If you've never thought about this before, your likely reaction is that the first example (inline) seems better. It takes up way less space and puts all the relevant information right on the button. Not so, according to the experts. The MDN Web Docs have this to say about using inline event handlers:

You can find HTML attribute equivalents for many of the event handler properties; however, you shouldn't use these — they are considered bad practice. It might seem easy to use an event handler attribute if you are doing something really quick, but they quickly become unmanageable and inefficient.

Or, in case that wasn't clear enough:

You should never use the HTML event handler attributes — those are outdated, and using them is bad practice. (emphasis theirs)

This is, in my polite opinion, completely wrong. The novices are right on this one. MDN is a tremendous resource, and I understand why they recommend the second form, but combating this particular ideology is essential to rehabilitating HTML's full functionality, and building durable applications with it. I'll explain.

<form> and function

MDN does not want you to use inline event handlers because they don't want you to mix form (HTML) and function (JS). This is a programming principle called Separation of Concerns and the HTML/CSS/JS split is a textbook example of it. From Wikipedia (at the time of this writing):

HTML is mainly used for organization of webpage content, CSS is used for definition of content presentation style, and JS defines how the content interacts and behaves with the user.

Separation of Concerns is a great principle, but I think the drew the line in the wrong place. In this conception of the web page, HTML is essentially the scaffolding that you dress up with CSS (for style) and JS (for interactivity). But HTML is inherently interactive, too. It's sufficiently interactive to power billion-dollar businesses without a single line of JavaScript.

Let's say you want to set up a text box and a search button so that people can search the web. You'd start with an <input type=text> for the text box, and a <button> to search.

  <input type=text name=q>

On the page it looks like this:

That button doesn't do anything; the text goes nowhere. But if you replace the <div> with a <form>, like so:

<form method=/search action=GET>
  <input type=text name=q>

then clicking the button submits your input as a query, and navigates to the result. So if you're on http://example.com and the text box has cats in it, clicking submit will navigate you to http://example.com/search?q=cats. The Google homepage worked exactly like this for a very long time.

HTML defined that functionality in its entirety. The page does something interactive—it makes a network request using your input, when you click the button—and no JavaScript was involved. It's easy to read, semantic, and will work in every web browser forever. Most importantly, it demonstrates that the entire concept of "HTML defines the layout, JS defines the functionality" is definitionally incorrect.

Enhancing the semantics

The problem with doing everything this way is that the functionality of HTML is extraordinarily limited, and to augment that functionality we need JavaScript. Form validation is a great example.

This form is a lot like the previous one, only now it asks for an email address of at least 8 characters:

  <input type="email" id="mail" name="mail" required minlength="8" />

That will get the job done, sort of. It will keep the user from submitting something that is too short, or doesn't look like an email, but it won't let you customize how the user is informed about the requirements of the email address. This form, adapted for clarity from the MDN page on form validation, demonstrates how to do that:

<form novalidate>
  <input type="email" id="mail" name="mail" required minlength="8" />
  <span class="error" aria-live="polite"></span>

Now there is a <span> that starts off empty, but will be populated with an error message if the email is invalid. There's also a novalidate attribute on the form that tells the browser not to do HTML's built-in validation because we're going to do it all ourselves in JavaScript. And here is the JavaScript that decides what the message is going to be, and adds it to the span.

const form = document.querySelector("form");
const email = document.getElementById("mail");
const emailError = document.querySelector("#mail + span.error");

email.addEventListener("input", (event) => {

  if (email.validity.valid) {
    emailError.textContent = "";
    emailError.className = "error";
  } else {

form.addEventListener("submit", (event) => {
  if (!email.validity.valid) {

function showError() {
  if (email.validity.valueMissing) {
    emailError.textContent = "You need to enter an email address.";
  } else if (email.validity.typeMismatch) {
    emailError.textContent = "Entered value needs to be an email address.";
  } else if (email.validity.tooShort) {
    emailError.textContent = `Email should be at least ${email.minLength} characters; you entered ${email.value.length}.`;

  emailError.className = "error active";

That code enhances the HTML so that it does the following things:

All those things aren't built into HTML, which is why you have to write them in JavaScript. A lot of JavaScript. But what if they were? Hypothetically, you could design the following interface in the HTML itself:

  <input type="email"
         value-missing-message="You need to enter an email address."
         type-mismatch-message="Entered value needs to be an email address."
         too-short-message="Email should be at least ${email.minLength} characters; you entered ${email.value.length}."
  <span id=email-error></span>

Instead of adding new messages in JavaScript, you write them on the input itself. That's better, for a couple reasons:

In some sense these are all the same advantage: they give HTML richer semantics.

Hopefully you're howling at your computer screen about this. "You didn't solve anything! Doing validation is complex and you just magic wanded it away by designing a perfect interface for it." Yes. Exactly. That is what interfaces are supposed to do. Better semantics make it possible for the programmer to describe what the element does, and for someone else to take care of the details for them.

I'm not saying you're not going to have to write JavaScript—someone's got to write JavaScript—but if we start writing our JavaScript libraries to enrich HTML's semantics, rather than replace them, we might get a lot more mileage out of both.

Keep in mind that, at this stage, the custom semantics I'm using are still purely theoretical. We'll talk about forwards compatibility, data- attributes, and all the hard details in a moment. The first task is to acknowledge that HTML, as a hypertext markup language, is inherently functional: the "hyper" denotes all the extra functionality, like links and forms, that we add to the text. You need to take HTML seriously to build good interfaces for it.

Once we do that, the task ahead is to figure out how best to augment its limited semantics with our own. That part is hard.

Back to reality

Okay, so if we want to enrich HTML's semantics, what are the right ways to do it?

The main concern here is that as HTML is both a living standard and a mercilessly backwards compatible one (it's a remarkable accomplishment that the first website ever is still online and displays perfectly on modern web browsers). So if I add too-short-messsage to my input element, and then a couple years in the future WHATWG adds a new too-short-messsage attribute, the page will start to break in unexpected ways.

Microformats are a very old standard that still gets some use today, perhaps most notably as part of the Webmentions specification. They let you add add properties as class declarations, like this:

<a class="p-name u-url" href="https://alexpetros.com">Alex Petros</a>

Something parsing the webpage will know that this link isn't just a random link, it's a link with my name as the text (p-name), and that person's home page as the URL (u-url). This is nifty but very limited. You could not implement a custom message using class names like this.

HTML solves this problem by reserving the data- prefix for custom attributes. This works fine, and some custom attribute libraries like Turbo embrace it. Take this example from their documentation, which uses the data-turbo-method attribute to change a link's method from GET to DELETE (I make no claims about whether that's a thing you should do):

<a href="/articles/54" data-turbo-method="delete">Delete the article</a>

And that works! That will never get overwritten by future updates to the HTML standard. If you want to write your whole attribute library that way, you can.

If I sound a little ambivalent about it, it's because I think everything about data-* attributes, from their name to the examples people use, suggests that they are meant to store data, not behavior. You can of course just barrel ahead and extend HTML with it, but the name and the verbosity really does discourage people from building semantics with it. If you say that data attribues are for "data that should be associated with a particular element but need not have any defined meaning," then people will use them that way.

We know this is true because some very popular JavaScript libraries eschew the data- attributes altogether and just add custom attributes with prefixes that are very unlikely to be added to HTML. Classic AngularJS uses ng-, which is still all over the internet today; Alpine.js prefixes its 15 custom attributes with x-; htmx does the same with hx- (although AngularJS and htmx both support prepending their prefix with data-, just for the pedants).

Browsers have supported this, unofficially for ages, and it also works well. Here's a button that toggles some arbitrary property using Alpine.js:

<button x-on:click="open = ! open">Toggle</button>

This is, in my opinion, the right general idea, even though I (subjectively) dislike almost everything about it. I find the open = ! open sort of weird (it's a global variable I guess?), having to namespace with x- is still a small kludge, and overall it deviates from HTML semantics in a way I don't vibe with. It's a very safe bet that WHATWG is not going to add x-on:click, but it's also, at the time of this writing, not a guarantee.

Custom attributes are (still) the way

In 2009, during the HTML5 specification process, John Allsop advocated for taking seriously the possibility of custom attributes in his blog "Semantics in HTML 5".

Instead of new elements, HTML 5 should adopt a number of new attributes. Each of these attributes would relate to a category or type of semantics. For example, as I’ve detailed in another article, HTML includes structural semantics, rhetorical semantics, role semantics (adopted from XHTML), and other classes or categories of semantics.

These new attributes could then be used much as the class attribute is used: to attach to an element semantics that describe the nature of the element, or to add metadata about the element.

He includes a couple examples, like one where you markup a paragraph as being ironic (I thought this was a ridiculous example until I remembered that people actually do this all the time, informally, with stuff like "/s"):

<p rhetoric="irony">He’s a fantastic person.</p>

Or this one that would let you specify times in a machine-paresable format (later solved with the introduction of the <time> element):

<span equivalent=“2009-05-01”>May Day next year</span>

This was the right path. The thing says what it is, and specifies machine-parseable semantics in the most human-readable way (although "equivalent" was a terrible name choice in that case).

There are still a lot of questions that need to be answered to make this work properly, which Allsop also acknowledged at the time:

I titled this section “some thoughts on a solution” because a significant amount of work needs to be done to really develop a workable solution. Open questions include the following.

Many of these questions still don't have good answers, because the field of web development mostly let this question go stale during its "screw it, JavaScript everything" phase. You don't need to extend the behavior of a form if you rewrite it every time. As we start to exit that era, I propose that we pick up where Allsop left off and begin doing to the work making HTML a safely extensible hypertext system.

One thing we can do immediately officially sanction is kebab-case attributes, roughly in line with this proposal (h/t to Deniz Akşimşek for showing me this). This would not only bless many of the most popular HTML-enhancing frameworks, and therefore huge chunks of existing code on the internet, with valid HTML, it would legitimatize the project of extending HTML with user- or library-defined semantics.

Okay Alex, how would you extend that button?

Remember the button from the beginning?

If you want to make a lot of buttons that display click messages, the best way to do that isn't with onclick or an event listener, it's to enhance the button so that you can turn any button into a message button.

<button alert message="I was clicked">Click me</button>

// Get all the buttons with the 'alert' attribute
const buttons = querySelectorAll('button[alert]')
buttons.forEach(btn => {
  // Get the message property of the button
  const message = btn.getAttribute('message')
  // Set the button to alert that message when clicked
  btn.addEventListener('click', () => { alert(message) })

This has the advantage of being re-usable across any button in your document. For less trivial applications, you can bundle that behavior into a library with a very nice interface (probably with a prefix, for now).

If you only need to do it for one or two buttons, though, just use onclick. It's less code, it doesn't require that you specify an id, and it doesn't make you hunt to a different part of the codebase to see what it does. Those are all "best practices" in my book.


Thanks to Deniz Akşimşek for reading a draft of this blog.

Discuss this blog on: HackerNews