Separation of logic and template in React using a "high order hook"

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.