Handling Events
There are two ways to handle events: inline and with the handleEvent
interface.
Inline events have been around since the birth of JavaScript, and the handleEvent
interface was introduced in 2000. It's supported in browsers all the way back to IE6. Inline events are the easy way to get events working fast. However inline events do not enable delegation. That means that if you create a list that has inline events on its items, and there are hundreds or thousands of items, they will eat up a lot of memory. Inline events should only be used for the simplest use case. The handleEvent
interface allows you to setup event delegation. You register an event on the component's base element and then use handleEvent
to manage what happens with the event targets. This results in minimal memory use.
It's surprising that after all these years, the vast majority of developers are unaware of how to use handleEvent
and what its benefits are. Developers are addicted to adding event listeners to elements. These you callbacks that are the source of serious memory leaks. This is because callbacks create closures that trap the reference to DOM nodes being manipulated. Even if you remove the event listener from the element, its callback's closure lives on, hold the reference to whatever nodes and objects it has. In contrast, you can implement handleEvent
as a method of your class component. Then your have access to all your class properties and methods through normal use of the this
keyword. Because you implemented handleEvent
as a method of your component, there is no callback and no closure. If you decide to remove the event, there is no cleanup necessary and never any memory leaks.
Inline Events
The easiest way to implement events is to put the event handler right on the element where you want to capture the interaction. Unlike other libraries that use JSX, there is no need to camel case inline events. Use them just like you would for JavaScript. However, one important thing to bear in mind. Inline the value of an inline event must never be quoted. Instead that value needs to be inclosed in curly braces. Let's make a simple class component and add an inline event to alert its value:
See the Pen Composi Tuts - events-1 by Robert Biggs (@rbiggs) on CodePen.
In our above example we did two things. We create a method to handle the event, announce
. And we put an online event handler directingly on the element we expect the user to click, in this case, the h1
. Notice that we use the this
keyword to access the component method inside the event handler. Now whenever the user clicks, they'll get an alert of the text of the h1
. The above example was very simple, we just used the event target to get the text content. If we need to access over parts of the component, it get's more complicated.
Binding "this"
We're going to change our Hello example so that the user can enter a different name. For that we'll need to add an input and a button to submit:
See the Pen Composi Tuts - events-2 by Robert Biggs (@rbiggs) on CodePen.
In this example, we've moved the onclick
inline event handler to the button. We expect that when the user enters a name in the input and clicks the button, that it will execute the component's announce
method. However, this is not working. Why?
Inline Events and Context
The above example does not work because the scope of the inline event is the element that was clicked. That means for the inline event the value of this
will be the button, not the component. We can verify this by logging the value of this
in the announce
method:
announce() {
console.log(this)
const input = this.element.querySelector('input')
const value = input.value
if (value) {
this.setState(value)
}
}
When you click the button, you'll see that the method outputs <button>Change Name</button>
in the browser console. In order to give the inline event the context of the component, we'll need to change our code. There are two ways to do this: using bind(this)
on the inline event, or simply enclosing the inline event in an arrow function. First let's look at using bind
:
<button onclick={this.announce.bind(this)}>Change Name</button>
Notice how we pass this
to the click handler using bind
. Doing this solves our problem and our example will now work. The announce
method can now access the component state because its reference to this
is correct:
See the Pen Composi Tuts - events-3 by Robert Biggs (@rbiggs) on CodePen.
Move Bind to the Constructor
Some of you may really hate the cluttered look of bind(this)
on your inline events. There is annother way to do this, and that is to bind the component method in the constructor. To do this your put the method you want to bind there and assign it the bound version. Below is our example updated with this approach:
See the Pen Composi Tuts - events-4 by Robert Biggs (@rbiggs) on CodePen.
With this change, our example now works and our online event is clean.
Use Arrow Function for Context
If you don't like having to bind your methods in the constructor, you could just use an arrow function in the inline event handler. Inside the arrow function you invoke the component method,. Because this happens inside the arrow function, the method has the scope of the component. Here's how you do that:
<button onclick={() => this.announce()}>Change Name</button>
Notice that we changed the event handler so that the arrow function returns this.announce()
. And that's it. The example now works as expected. Notice for the onclick
for announce
that we had to pass in the event object to the arrow function and the method invocation:
See the Pen Composi Tuts - events-5 by Robert Biggs (@rbiggs) on CodePen.
Summary
When using inline events you have three ways of getting the component scope. This is purely user preference. There is no difference in result or unexpected gotchas with any of the three.
handleEvent
Besides inline events, you can also use the more powerful handleEvent
interface. We prefer this over inline events for the following reasons:
- It results in cleaner component markup--no inline events.
- Event delegation. Really important when handling events on items of long list.
- No callbacks! Everything is a component method.
- No memory leaks caused by callback closure.
- One centralized place to handle all of the component's events
- Easy to remove or modify events.
- No binding issues with
this
. The scope will be the class itself. - If you accidentally bind the same event more than once, because it is the same
handleEvent
object, the browser will only fire it once.
What the Heck is handleEvent?
The TL:DR is, it's a replacement for event callbacks. Instead of writing a callback for the event, you pass it an object with a handleEvent
method. When the browser sees that your provided an object for an event, it checks to see if there is a handleEvent
method. If there is, it uses that for the event. When using this interface with a class component, we give the component a method called handleEvent
and then pass the component itself to the event. That way the event examines the component class, finds the handleEvent
method and uses that. Because the handleEvent
is a method of component class, it has access to all the properties and methods of the class.
Time to refactor our Hello
component using handleEvent
:
See the Pen Composi Tuts - events-6 by Robert Biggs (@rbiggs) on CodePen.
Our class now has a handleEvent
method. Because it will be invoked by the event, it gets the event object as its parameter. We can use that to check what it was the user clicked. Since we want to capture a click on the button, we check the event target node name. If it equals "BUTTON", we execute the announce
method. In the example above we are using the && operator to set up a conditional check. You can read more about using it in the tutorial for Conditional Rendering. At the moment the above example does not work because it's lacking the initial event delegation. We handle that next with a lifecycle hook.
Use a Lifecycle Hook to Add Event
Although we have a handleEvent
method on our component, click the button does nothing. Remember, handleEvent
is a replacement for the event callback. To work, handleEvent
needs an addEventListener
registered on the component. We can do that using a lifecycle hook. The one to use for this is componentDidMount
. As soon as the component is injected in the DOM we want to attach an event to it to use our handleEvent
method:
See the Pen Composi Tuts - events-7 by Robert Biggs (@rbiggs) on CodePen.
Notice how we set up the event listener in the componentDidMount
hook. We designated a click
event, and then we passed in this
instead of a callback. Here this
is the class itself. This means the scope of the handleEvent
method will be the class itself, giving us access to all its properties and methods from inside the handleEvent
method. There will be no weird binding issues like inline events.
We registered the addEventListener
on the component's element
. For a component the element
property is the base parent element of all the other child elements. In the case of our Hello
class, that will be the div tag. Because the click event is registered on the div, we can use the event target in the handleEvent
method to test for many possible interactions. This gives use the ability to handle user interactions through event delegation in with a simple pattern of testing the event target, followed by &&
and the class method to execute.
Event Delegation
Event delegation is an important technique to reduce memory use. In out above example we handle it fairly simply, mostly because there was only one event target to test for. In a complex component your might have many event targets. In such a case you need to add a test for each target you are interested in. To make that easier you might want to use ids or classes to be more specific:
handleEvent(e) {
const id = e.target.id
e === 'add-item' && this.addItem()
e === 'remove-item' && this.removeItem()
}
By registering the click event on the component base element, we can test for as many event targets as we need.
Dealing with Nested Children
All the parents out there will probably sigh in agreement with the next state. Managing children can be complicated. We're talking about capturig the correct event target when dealing with a complex component stucture. To illustrate this, let's image we have a component that creates a list with list items like the following:
<li>
<h4>{person.name}</h4>
<h5>{person.job}</h5>
</li>
We want to capture a user click on the list item so we can alert the text content of the list item. You would think that we could do this:
handleEvent(e) {
e.target.nodeName === 'LI' && this.announce(e)
}
announce(e) {
alert(e.target.textContent)
}
If you implement a component list with this event handling, you will find that its behavior is not consistent. Most of the time when you click on the list item, it does nothing, but sometimes when you click somewhere inbetween the child element, you get the announcement. What is going on here is that we are testing for an event target of LI
. Most of the time you would be clicking on click components, either h4
or h5
. Therefore these would not be caught by our target check. To handle this we could update our code like this:
handleEvent(e) {
// The user clicked on the list item itself:
e.target.nodeName === 'LI' && this.announce(e)
// The user clicked on an H4:
e.target.nodeName === 'H4' && this.announce(e)
// The user clicked on an H5:
e.target.nodeName === 'H5' && this.announce(e)
}
With this change, you can now click anywhere on the list item and you will get the announcement. However, this is ugly, and not necessary. There is an easier way.
Use Closest for Event Delegation
Modern browsers have an Element method called closest
. If you've used jQuery in the past, this works exactly the same as the jQuery function closest
. You execute it on an element and pass it a selector to find the closest match. As we mentioned, this is available in modern browsers, including Microsoft Edge. However, if you need to support IE 9, 10 or 11, you can use the polyfill. Here's the previous example redone with Element.closest
to clean up our last example:
handleEvent(e) {
// The user clicked on the list item:
e.target.nodeName === 'LI' && this.announce(e)
// The user clicked on some child element, so use closest:
e.target.closest('li') && this.announce(e)
}
Now this list item can have as many child elements as needed without making it complicated to capture the event target.
Removing the Event for handleEvent
If you plan on deleting a component from the DOM, you need to unbind its eventListener. This is really easy when you use handleEvent
. Just provide the event and this
. Composi provides a method to remove a component: unmount
. To remove the event and unmount the component, we could do this:
// Remove the event listener from the component component.
// We used handleEvent, so we pass "this" as second argument:
clock.element.removeEventListener('click', this)
// Unmount the component:
clock.unmount()