React Eventing Like The Dom

05 Aug 2024 - Ben

React doesn’t have a builtin eventing system like the browser does, but you can model your components after it. You can leverage the DOM Events through on* handler props on JSX elements, which produce Synthetic Events and gives access to them, but you can’t easily fire your own Custom Events off of the Nodes React creates. This means any event you want an element to listen to needs to get an event handler or signal or callback from somewhere. Often that’s the state management library, but it might just be prop drilling.

So sticking with the Synthetic Event model, we can write a familiar interface for our own components. onClick is called everytime the mouse clicks that element, onChange will be called when the input changes (weirdly on an input, that’s the browser’s oninput event). When we’re building our own components, we should strive to do the same thing. Have events as callbacks that start with “on___”. Give access to the event that is finally fired from the browser, if you can. The higher level component can tie into state management for us; that’s a nice separation of concerns and allows us to reuse our component in more places. This will push us to think like the nodes we’re manipulating, the DOM.

Example

Have a form that can be submitted or skipped? Have the components that encapsulates the form include onSubmit and onSkip props. What fires the onSubmit? Form submission should. Then have that callback take two arguments, the processed form data (for ease of use) and the React.FormEvent<HTMLFormElement> event that the form element has, to give as much information as we have access to. How do you skip? When a button is pressed? Great, just have that whole callback be a React.MouseHandler<HTMLButtonElement>. Oh, you want to call onSkip if the form is submitted empty as well? Then change the type to (event: React.MouseEvent<HTMLButtonElement> | React.FormEvent<HTMLFormElement>) => void;. Maybe that isn’t quite as pretty, but it’s honest and actually gives information about how the event was triggered, unlike a simple () => void; that I see in a lot of code.

Setting up your components like this might be more work initially, but like refs it helps us focus our effort in making components that work with the browser model better. It forces us to think about component boundaries in a way that we can work with and learn about the DOM as we’re building. The React goodness on top and not being tied directly to the DOM allows us to contextualize what we’re writing to the domain at hand. Put another way, this style helps ground us in the rendered truth of the browsers DOM while letting us express our problem domain.

What does it look like in practice? In the simple example, with no preprocessing:

type NameFormProps = {
  onSubmit: React.FormEventHandler<HTMLFormElement>;
  onSkip: React.MouseEventHandler<HTMLButtonElement>;
}
function NameForm({onSubmit, onSkip}: NameFormProps) {
  return (
    <form onSubmit={onSubmit}>
      <input name="name" />
      <button type="submit">Submit</button>
      <button type="button" onClick={onSkip}>Skip</button>
    </form>
  )
}

A little more complicated, to tie into React better:

type NameFormProps = {
  onSubmit: (name: string, event: React.FormEvent<HTMLFormElement>) => void;
  onSkip: (event: React.MouseEvent<HTMLButtonElement> | React.FormEvent<HTMLFormElement>) => void;
}
function NameForm({onSubmit, onSkip}: NameFormProps) {
  const submitHandler = useCallback((ev) => {
    ev.preventDefault(); // stop the network request forms make by default
    // collect the name field from the form
    const formData = Object.fromEntries(new FormData(ev.target).entries());
    if(formData.name.trim()) onSubmit(formData.name, ev);
    else onSkip(ev);
  }, [onSubmit, onSkip]);
  return (
    <form onSubmit={submitHandler}>
      <input name="name" />
      <button type="submit">Submit</button>
      <button type="button" onClick={onSkip}>Skip</button>
    </form>
  )
}


Can the browser do this? What would it look like? I mentioned Custom Events above, here’s my article about implementing it that way