< Blog roll

When to emit events for stateful components

This post represents a debate that I had at work in order to make some decisions about how a component should emit its custom events and how we would holistically handle event emission across all of our components.

I am seriously considering titling my blog as “Stuff I debate with my coworkers about” because its very much starting to feel like that‘s where I get most of the content I want to write about.

State changes that require emitting events can be tricky#

Web components have state (attributes and properties). That state can change. Those state changes can be driven by developers setting attributes and/or properties (unlike framework-land, web-component-land has both) and by users interacting with the component in a way that causes that state to change.

The example at hand is a user clicking a button that closes a dialog or dismisses an alert. Let‘s say you have a web component for your alert, that has an open prop—typing “attribute/property gets tiresome, so from now on I’ll just say “prop”. So the user clicking that button when the alert is visible is some click handler like this:

handleClick() {
  // user clicks button
  this.open = false;
}

And our developer consumers need to know when the alert is dismissed so they can do some fancy analytics tracking, or fetch some data or something. So we need to emit an event signaling “Hey code, the alert got dismissed.”

But your developer consumers can also use that same property programmatically to force the alert closed for some reason. Maybe the user clicked the reset button on the form the alert is next to so we need to dismiss the alert but the user didn’t do it.

Preventable events#

Another piece of context that this debate requires is that the dismiss event for the alert is preventable. I wrote an article a few years ago that goes into detail about how to implement preventable events to keep your component stateful (uncontrolled) but still allow for your dev consumers to take control of the component if the need arises.

So that means our click handler for the dismiss button of our alert looks like something more like:

handleClick() {
  // user clicks button

  const event = this.emitPreventableEvent('dismiss');

  if(!event.defaultPrevented) {
    this.open = false;
  }
}

Because the event is preventable, we only do the state change automatically if the dismiss never has its .preventDefault() method called on it.

The crux of the debate#

So as of now our user action is handled perfectly. Our end user can click the dismiss button, our dev consumer gets an event to listen to for their fancy analytics, and everyone is happy. Until the developer needs to programmatically set open to false themselves.

Question: Do we still emit the event when programmatic state changes occur?

As I see it, there are a couple of options that we could choose from:

I‘ll get to what my choice was and what our group has settled on as our approach going forward later on. Feel free to skip ahead to the decision section. If you‘re not skipping, here‘s the pros and cons of the first two options as I see them.

Option 1: Don‘t emit events for programmatic state changes

Pros

Cons

Option 2: Always emit events even for programmatic state changes

Pros

Cons

Preventable event infinite loop scenario#

In the case that you‘ve read my article on preventable events, think I‘m a genius and have started using them as a pattern, you‘ll know that the point of preventable events is for developers to be able to take programmatic control over an otherwise automatic state change the component usually does on its own. If you also decide that events are always generated, even by programmatic control, then you’ll get something like this:

  1. User clicks button
  2. Preventable event is emitted
  3. Handler is called and has event.preventDefault()
  4. open is programmatically set to false after fancy analytics
  5. Preventable event is omitted
  6. Handler is called and has event.preventDefault()
  7. open is programmatically set to false after fancy analytics
  8. Preventable event is omitted
  9. Handler is called and has event.preventDefault()
  10. open is programmatically set to false after fancy analytics
  11. Preventable event is omitted
  12. Handler is called and has event.preventDefault()
  13. open is programmatically set to false after fancy analytics
  14. Preventable event is omitted

…the page crashes.

In order to prevent the infinite loop, some sort of external state must be tracked in order to decide whether or not to call event.preventDefault() and we‘re right back to the main negative which is that its hard to figure that out.

What do all our friends do?#

We‘re lovers of a few different web component libraries, so naturally we checked out Shoelace and Web Awesome–same thing I know–to see what they do. They basically always emit events and some components can be tricky to programmatically operate depending on the event type. More good friends of ours over at Adobe Spectrum Web Components seem to only emit events on user generated state change. I checked Fluent UI web component also but couldn‘t find a great example of this use case, so who knows what they‘ll end up doing.

I‘ll tell you my thoughts after you tell me yours

Where we ended up#

We ended up with super secret option 3 which is:

Always emit events for state changes that need them, but preventable events should be conditionally preventable based on their origin.

This decision is a bit tricky to explain, so I‘ll show some code (in Lit because that‘s what I like and use daily.

How it works#

The code samples I showed before were a lie. Well, they were simplified. Our helper function for emitting events takes the event name, all the event options (bubbles, cancelable, composed, etc) and a callback function to execute for cancelable state changes. The helper function orders the state change callback depending on whether or not the event is cancelable–a cancelable event‘s preventDefault() method works, non-cancelable events preventDefault() methods don‘t. If the event being emitted is cancelable, the state change is run before the event so event handlers have the most update to date version of component state, otherwise for cancelable events the state change is only run if the event isn‘t prevented and happens after all event handlers are run.

Also, we combine the implementation of the open property with the button click so that both actually call the same function. So our code actually looks more like this:

// alert-class.js

handleDismissClick() {
  this.#dismissAlert('dismiss-button');
}

#dismissAlert(source?: 'dismiss-button' | 'click-away') {

  this.emit({
    name: 'dismiss',
    options: {
      cancelable: source
    },
    // there's also a detail object where we provide the current value of `open` and the next/new one
    callback(){
      //state changes, before event if not cancelable, after event if cancelable and not prevented
      this.open = false;
    }
  });
}

// Lit lifecycle function, runs before the component is re-rendered
// property values changed here don't cause another re-render
willUpdate(changed) {
  if(changed.has('open)) {
    if(this.open) {
      this.#openAlert();
    } else {
      this.#dismissAlert();
    }
  }
}

With this implementation our new flow for the preventable dismiss event looks like this:

  1. User clicks button
  2. Preventable event is emitted
  3. Handler is called and has event.preventDefault()
  4. open is programmatically set to false after fancy analytics
  5. Non-preventable event is omitted
  6. Automatic state change sets open to false
  7. event.preventDefault() in the event handler is no longer functional, but fancy analytics can still run
  8. Programmatic setting of open to false in the event handler doesn‘t cause a re-render because open is already false
Chat with me about this approach

Conclusion#

Is this implementation the best of both worlds? I have no idea! But I like it.

The End