Intro
One of the things that I find somewhat "bothering" with React, is the lack of separation of concerns regarding logic (i.e. event handlers and events) and UI templating (i.e. markup). With the way React works it is impossible to avoid a "mixture" of the two completely, but there's a trick you can use to have the events logic and the template markup separated from each other.
Event Delegation
The trick is to make use of Javascript's event delegation capabilities - we do that by attaching a single event handler for each desired event type, to the root element of a component, and then call a relevant function according to specifications from a single united "events object", passing it the DOM element that triggered the event (the one in event.target
)
High Order Hook
The functionality will be implemented within a sort of "high order" hook function which we'll call useEvents
- the hook can be used on the returned template of a functional component (as well as on a rendered template in a class component). We will use the useEffect
hook to attach the single event handler, and we will use the useRef
hook to obtain a reference to the root DOM element:
import { useRef, useEffect } from 'react';
function useEvents(component, events) {
const rootRef = useRef(null);
useEffect(()=> {
for (let eventName in events) {
rootRef.current.addEventListener(eventName, function(event) {
let targetName = event.target.dataset.name;
if (targetName in events[eventName])
events[eventName][targetName].call(event.target, event);
});
}
});
return (
<div ref={rootRef}>
{ component }
</div>
)
}
Events Object
As you can see, the useEvents
receives a component and an events
argument. the events
needs to be an object, with its "upper layer" as properties named after DOM events (click
, keyup
etc.), each property will have "names" as keys, and functions as values. The "name" will describe a DOM element that we'd like to handle an event that's triggered from it, the function will be the actual event handler. For this to work, we'll also have to set the names on each element in the template, we'll do that using a data-name
attribute (as you can see in the example in the next section).
An "events object" can look something like this:
{
click: {
button1() { alert("You clicked button1!"); },
button2() { alert("You clicked button2!"); },
theinput() { alert("you clicked the input!") },
},
keyup: {
theinput(event): { alert (`You pressed ${event.key} on input`); }
}
}
In the code from the High Order Hook section, you can see that we wrap the passed component with a root div, so we can obtain a ref
to it. We then add an event listener for each type of events specified in the "events" object. The event listener checks the "name" of the element that triggered the event (event.target
) and if it exists in the "events object" - it triggers the handler, making sure the this
context is the triggering element, and passing it the event
object.
An example
In this example we'll define a functional component called Trigger
, which will have two buttons and an input field. It will track the click
events for all three elements, and will also handle the keyup
event on the input. The handler for the keyup
will be passed as a prop, to demonstrate how you can still do it with the useEvents
hook. The full example looks like this:
import { useRef, useEffect } from 'react';
function useEvents(component, events) {
const rootRef = useRef(null);
useEffect(()=> {
function handleEventType(event) {
let targetName = event.target.dataset.name;
if (targetName in events[eventName])
events[eventName][targetName].call(event.target, event);
});
}
for (let eventName in events) {
rootRef.current.addEventListener(eventName, handleEventType);
}
return () => {
for (let eventName in events) {
rootRef.current.removeEventListener(eventName, handleEventType)
}
}
});
return (
<div ref={rootRef}>
{ component }
</div>
)
}
function Trigger(props) {
return useEvents(
<div>
<h3>Click on button, tap keyboard keys on input</h3>
<button data-name="button1" style={{margin:"0 6px"}}>Button 1</button>
<button data-name="button2" style={{margin:"0 6px"}}>Button 2</button>
<input data-name="theinput" style={{margin:"0 6px"}}></input>
</div>,
{
click: {
button1() { alert("You clicked button1!"); },
button2() { alert("You clicked button2!"); },
theinput() { alert("you clicked the input!") },
},
keyup: {
theinput: props.inputKeyUp
}
}
)
}
function App() {
return (
<div className="App">
<Trigger
inputKeyUp={event=> { alert (`You pressed ${event.key} on input`); }} />
</div>
);
}
export default App;
Notice how the 4 event handlers are completely separated from the templating part in the Trigger
component, and are tidied up together in the same object, instead of being scattered all over the place. This can be shortened further, if you put the component and events object inside variables, and then you could do:
function Trigger(props) {
return useEvents(component, events);
- As commented by fixrich on a reddit thread, the hook should also remove event listeners upon
unmount
, this is edited in the full example, by adding a returned function that removes the listeners.
Here's a full working example in Codepen,
and here's a gist with the useEvents
hook.