Async Events in the DOM

They don't exist.

There was a recent twitter thread that linked to a GH Discussion, which killed this Definitely Typed PR.

And I think that's the right call, but let's dig into why I think that.

What was happening?

Let's write a very basic, vanilla HTML form. Other than clicking buttons, a form submission is probably where a lot of folks start with events on the Web:

form.html

<h1>Test Form</h1>
<form id="$testForm">
  <label>Name:</label>
  <input type="text" />
  <br />
  <label>Age:</label>
  <input type="number" min="1" max="150" />
  <hr/>
  <button type="submit">Submit</button>
</form>
<script type="module" src="form.js"></script>

form.ts (with an emit to form.js)

declare const $testForm: HTMLFormElement;

function handleSubmit(event: SubmitEvent) {
  console.info('hai');
  event.preventDefault();
}

$testForm.onsubmit = handleSubmit;

Because the form submission triggers a full page load, we are losing a benefit of SPAs by keeping the user on a single session/page. To prevent the page load, while retaining form semantics (and browser behaviours like Enter), we can call event.preventDefault. This gives us a decent starting place:

  function handleSubmit(event: SubmitEvent) {
    console.info("some work!");
    event.preventDefault();
  }

We can write a quick little helper to transform the form's data into some JSON so it is ready to POST to an endpoint:

function formToJson(formElement: HTMLFormElement) {
  const formData = new FormData(formElement);

  let formJson: Record<string, string> = {};
  for (const [fieldName, fieldValue] of formData) {
    formJson[fieldName] = String(fieldValue);
  }

  return formJson;
}

And now we're ready to post!

function handleSubmit(event: SubmitEvent) {
  const data = formToJson(event.currentTarget);
  postSubmission(data);
  
  event.preventDefault();
}

But what happens when we want to do inline refinements before and/or after the actual submission? That's easy enough with a promise chain...

function handleSubmit(event: SubmitEvent) {
  const data = formToJson(event.currentTarget);
  
  // imagine we were good and had nice functions to compose together
  validateData(data)
    .then(postSubmission)
    .then(toastUser)
    .then(resetForm)
    .catch(handleError);
  
  event.preventDefault();
}

But some folks might want to leverage async/await in this case.

async function handleSubmit(event: SubmitEvent) {
  const data = formToJson(event.currentTarget);
  
  try {
    const validatedData = await validateData(data);
    const submission = await postSubmission(validatedData);
    toastUser();
    resetForm();
  } catch(ex) {
    handleError(ex);
  } finally {
    event.preventDefault();
  }
}

At first blush this may seem fine, but our event is being referenced after some async work is done, so it does nothing and we regress back to default form submission potentially interrupting the promise workflow we kicked off in async/await land. Yikes!

The fix is to handle all event.* invocations synchronously.

async function handleSubmit(event: FormEvent<HTMLFormElement>) {
  event.preventDefault();
  const data = formToJson(event.currentTarget);
  
  // imagine we were good and had nice functions to compose together
  try {
    const validatedData = await validateData(data);
    const submission = await postSubmission(validatedData);
    toastUser();
    resetForm();
  } catch(ex) {
    handleError(ex);
  }
}

And one more thing! What if the user smashes enter a lot? (or any button with an onClick) What happens then? Oops, our client now POSTs incessantly to our server. Oh and another thing here - we call handleError here as an imaginary handler, but imagine it shows an error to the user (shocker) - what happens when the user is smashing enter or dbl clicks our button without the below fix? Or what if the event fires, but the user sees something wrong, and fixes it quickly and fires it again during server processing - which update wins and what feedback is shown to the user? Suffice to say, async workflows mixed with user signaling/events is rife with subtle issues that can cause headaches, bugs, and poor UX.

We'll have to introduce more state to fix that for our simple case.

let inProgress = false;

async function handleSubmit(event: FormEvent<HTMLFormElement>) {
  event.preventDefault();
  if (inProgress) {
    return;
  }
  inProgress = true;
 
  const data = formToJson(event.currentTarget);
  
  // imagine we were good and had nice functions to compose together
  try {
    const validatedData = await validateData(data);
    const submission = await postSubmission(validatedData);
    toastUser();
    resetForm();
  } catch(ex) {
    handleError(ex);
  } finally {
    inProgress = false;
  }
}

Even though we fixed our problems here, can we eliminate at least one of these classes of errors?

The signature of addEventListener tells us that Event Listeners are Functions taking in some Event and returning any or void (more on this in the TypeScript section). And even though there isn't any type error (yet) when binding async functions to event handlers, what does the browser do with event handlers?

🖼️
Big Picture :: Once something async begins, you are no longer able to reference the event - and in fact you are outside of the event entirely. Meaning the browser, DOM APIs, and React are all not aware of the execution of your code.

Async workflows outlive Events.

Basically, Browsers do two things with this API - subscribe and notify (excuse the crude psuedocode, I did not want to go find some cursed spread out cpp code):

// SUBSCRIBE
function addEventListener(this, eventName, fn) {
  const elementGuts = getElementMetadata(this);
  elementGuts.listeners[eventName].append(fn);
}

// NOTIFY!
function fireEvent(el, event) {
  const elementGuts = getElementMetadata(this);
  const fns = elementGuts.listeners[event.name];
  for (const fn on fns) {
    fns(event);
  }
}

This is overly simplistic, because bubbling and the tree are omitted (which are like... kind of important) but the idea is there - Events have two (three if you count removing) important parts. Subscribing, and Notifying. That's it. When notifying the browser has to go through the list of subscribers and invoke them, synchronously, in some loop somewhere, and handle bubbling.

I want to stress, the fns(event) invocation above (plus bubbling) is the only thing browsers care about when it comes to invoking your Event. There is no special handling of Promises, no regard for any return value, and you are being invoked in a hot browser internal path (ie some loop on the render thread).

What is an Event Handler?

The MDN docs and React docs do a great job, but the important point I want to stress are that events are signals. And in React terms, events are a great place for side effects to occur, and the react docs outline how and when you might think about reaching for useEffect or use an event.

DOM, and React, use Events as signals to be as close to the user as possible. This puts me in game engine mode. I'm in the main game loop when I'm in an event (especially those onChange events!), and when I'm operating in this mode, I take extra care to make future me and my team successful.

The exact technical reason this is important for most React Apps, is that neither the DOM's nor React's Event System do anything with Promises. Events, when fired, execute their handlers synchronously and obey bubbling/propagation. The fact anything was ever returned at all from Event Handlers is more of an interesting historical fact than anything.

And async functions are functions that return promises by definition. If I made an API accept a Promise, I would have certain expectations to fulfill about how I would consume a promise. I would probably, internally, await that promise, and return a Promise that had your passed in promise resolved. You know, normal reasonable things. But in Event Handler land, what does it mean to have an async event handler?

It means nothing.

The browser does nothing differently when it sees an event handler return a Promise - other than seeing you have returned a truthy value which doesn't fall into the false category so they don't do all that quirky stuff that we now have proper DOM APIs for.

The only benefit for having an async event handler is for our own convenience as developers. We wave away whatever the 'platform' is doing with our handler, and just overload and collate all our async workflows in our event handler because, well, we are kicking off async workflows after all.

TypeScript

Does TypeScript say anything about this? Well, interesting it does seem to expect event listeners to be void, but has some affordances for any returning Event Listeners since they 'do no harm' and TS has always been about maximum ecosystem compatibility. I think we should be explicit.

function handleSubmit(event: SubmitEvent): void {
  // ... snip ...
}

Unfortunately, if you try to extract this out into an interface or type alias like TS has with EventListener, TS becomes lax again and lets boolean/false/Promise return values fly through. An explicit void return prevents this. If you know of a compiler option or even the reason this is the case, please @ me on twitter!

Shields & Roads

Now that we've reviewed how the Browser thinks about Events and Effects, how might we go about our over simplistic form submission example? It is fine to fire async work off from an event handler, but we can adopt a pattern of separation to shield our future and teams.

Our example above is fairly straightforward, we can just add another function:

// collate all async work. No reference to DOM / Events
async function processSubmission(data: Record<string, string>) {
  try {
    const validatedData = await validateData(data);
    const submission = await postSubmission(validatedData);
    toastUser();
    resetForm();
  } catch(ex) {
    handleError(ex);
  }
}

// keep event handler sync, fire off async work
function handleSubmit(event: FormEvent<HTMLFormElement>) {
  event.preventDefault();

  const data = formToJson(event.currentTarget);
  processSubmission(data);
}

For more complex cases you may need to transform or extract more information off the event before handing off to multiple async workflows - but the basic pattern is there.

🏗️
We focused on just plain HTML/JS (TS) here, but it's all the same in React. The only difference is there is a layer (React's synthetic events) in between your code and the Browser, but the exact same thing happens at the end of the day. React can batch updates and do other clever optimizations, but something somewhere is looping over subscribers and invoking functions - synchronously - for any event in the DOM to work.

To operationalize this, right now, one could imagine/create a linter that looks at all event handlers and ensures event never outlives its intended scope (smells like ownership!), but what's even better, is the proposed change in the Type Signature - ensuring void returning sync functions for all event handlers.

I think this type change paves a road for how to best leverage event handlers that is more in line with the platform, entirely preventing a class of bugs (when paired with that smart ownership-adjacent linter) that might be pretty confusing if an event got referenced deep in some callback or promise chain.

The Future

In the Future, React will straddle the Network Boundary, and will offer a higher level API for dealing with forms called Server Actions. You can read and experiment with that by reading this and installing canary. Server Actions live on top of DOM Events to allow you the best in class DX - so keep your eyes peeled!