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:
- No, don‘t emit events on programmatic state changes
- Yes, always emit events, even for programmatic state change
- Super secret third option
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
- Devs easily know that events are only generated by users
- Easier to write event handlers if user generated events are mostly what devs care about using
Cons
- Trickier to implement in the component, because your event emitting logic needs to be conditional
- Harder to document because events are conditional and those conditions need to be described and kept accurate if they change
Option 2: Always emit events even for programmatic state changes
Pros
- Component logic is simpler because event emitting isn‘t conditional
- Easier to document because events are not conditional
Cons
- Trickier for devs to figure out if an event was user-generated or not
- Preventable events have infinite loop possibilities if not implemented correctly*
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:
- User clicks button
- Preventable event is emitted
- Handler is called and has
event.preventDefault()
open
is programmatically set to false after fancy analytics- Preventable event is omitted
- Handler is called and has
event.preventDefault()
open
is programmatically set to false after fancy analytics- Preventable event is omitted
- Handler is called and has
event.preventDefault()
open
is programmatically set to false after fancy analytics- Preventable event is omitted
- Handler is called and has
event.preventDefault()
open
is programmatically set to false after fancy analytics- 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:
- User clicks button
- Preventable event is emitted
- Handler is called and has
event.preventDefault()
open
is programmatically set to false after fancy analytics- Non-preventable event is omitted
- Automatic state change sets
open
tofalse
event.preventDefault()
in the event handler is no longer functional, but fancy analytics can still run- Programmatic setting of
open
tofalse
in the event handler doesn‘t cause a re-render becauseopen
is already false
Conclusion#
Is this implementation the best of both worlds? I have no idea! But I like it.
- Events are always emitted
- Developers can use
event.cancelable
in handlers for conditional logic if they need to - Cancelable events are only emitted on user actions
- We can predictably document the component‘s behavior and it wont be as conditional and subject to change
- No infinite loops!
The End