5 Critical Tips for Composing Event Handler Functions in React

Christopher T.

May 17th, 2020

Share This Post

JavaScript is praised for its unique ways to compose and create functions. That's because in JavaScript, functions are first class citizens meaning they can be treated as values and have all of the operational properties that others have like being able to be assigned to a variable, passed around as a function argument, returned from a function, etc.

We will be going over 5 critical tips to compose event handlers in react. This post will not cover everything that is possible, but it will cover important ways to compose event handlers that every react developer should know, minimally!

We're going to start with an input element and attach a value and onChange prop to start off:

import React from 'react'
import './styles.css'

function MyInput() {
  const [value, setValue] = React.useState('')

  function onChange(e) {
    setValue(e.target.value)
  }

  return (
    <div>
      <input type="text" value={value} onChange={onChange} />
    </div>
  )
}

export default MyInput

Our event handler is the onChange and the first argument is the event object coming from the element that the handler was attached with.

What can we improve on from here? Well, it's generally a good practice to write components that are reusable, and we can make this reusable.

1. Move the setter in a higher level

One way is to pass the responsibility of setting the value state up to the props so that other components can reuse this input:

import React from 'react'
import MyInput from './MyInput'

function App() {
  const [value, setValue] = React.useState('')

  return <MyInput value={value} />
}

export default App

That means we would also have to give control over the event handler (which holds the state setter) to the parent:

function App() {
  const [value, setValue] = React.useState('')

  function onChange(e) {
    setValue(e.target.value)
  }

  return <MyInput value={value} onChange={onChange} />
}
function MyInput({ value, onChange }) {
  return (
    <div>
      <input type="text" value={value} onChange={onChange} />
    </div>
  )
}

But all we did was move the state and the event handler to the parent and ultimately our App component is the exact same as our MyInput, only named differently. So what's the point?

2. Wrap your event handlers if more information could be needed for extensibility purposes

Things begin to change when we start composing. Take a look at the MyInput component. Instead of directly assigning onChange to its input element, we can instead give this reusable component some additional functionality that make it more useful.

We can manipulate the onChange by composing it inside another onChange and attach the new onChange onto the element instead. Inside the new onChange it will call the original onChange from props so that the functionality can still behave normally--as if nothing changed.

Here's an example:

function MyInput({ value, onChange: onChangeProp }) {
  function onChange(e) {
    onChangeProp(e)
  }

  return (
    <div>
      <input type="text" value={value} onChange={onChange} />
    </div>
  )
}

This brings the awesome ability to inject additional logic when the value of the input changes. It behaves normally because it still calls the original onChange inside its block.

For example, we can now force the input element to accept only number values and only take in a maximum of 6 characters in length, which is useful if you we want to use this for verifying logins through user's phones:

function isDigits(value) {
  return /^\d+$/.test(value)
}

function isWithin6(value) {
  return value.length <= 6
}

function MyInput({ value, onChange: onChangeProp }) {
  function onChange(e) {
    if (isDigits(e.target.value) && isWithin6(e.target.value)) {
      onChangeProp(e)
    }
  }

  return (
    <div>
      <input type="text" value={value} onChange={onChange} />
    </div>
  )
}

In reality though, this could've all still been implemented in the parent App without any problems so far. But what if onChange handler in the parent needs more than just the event object from MyInput? The onChange handler there no longer becomes useful:

function App() {
  const [value, setValue] = React.useState('')

  function onChange(e) {
    setValue(e.target.value)
  }

  return <MyInput value={value} onChange={onChange} />
}

But what can App possibly need other than the event object and knowing that a value of the element is changing, which it is already aware of hence being inside the execution context of the onChange handler?

3. Take advantage of the original handler that was composed through arguments

Having direct access to the input element itself can be extremely helpful. That means its useful to have some ref object passed in along with the event object. It's easily done since the onChange handler was composed here:

function MyInput({ value, onChange: onChangeProp }) {
  function onChange(e) {
    if (isDigits(e.target.value) && isWithin6(e.target.value)) {
      onChangeProp(e)
    }
  }

  return (
    <div>
      <input type="text" value={value} onChange={onChange} />
    </div>
  )
}

All we need to do is declare the react hook useRef, attach it to the input and pass it along inside an object as the second parameter to onChangeProp so the caller can access it:

function MyInput({ value, onChange: onChangeProp }) {
  const ref = React.useRef()

  function onChange(e) {
    if (isDigits(e.target.value) && isWithin6(e.target.value)) {
      onChangeProp(e, { ref: ref.current })
    }
  }

  return (
    <div>
      <input ref={ref} type="text" value={value} onChange={onChange} />
    </div>
  )
}
function App() {
  const [value, setValue] = React.useState('')

  function onChange(e, { ref }) {
    setValue(e.target.value)

    if (ref.type === 'file') {
      // It's a file input
    } else if (ref.type === 'text') {
      // Do something
    }
  }

  return (
    <div>
      <MyInput value={value} onChange={onChange} />
    </div>
  )
}

4. Keep the signature of the higher order function handler and the composed handler identical

It's generally a very important practice to keep the signature of composed functions the same as the original. What I mean is that here in our examples the first parameter of both onChange handlers are reserved for the event object.

Keeping the signature identical when composing functions together helps avoid unnecessary errors and confusion.

If we had swapped the positioning of parameters like this:

swapped parameter positioning in javascript react event handlers

Then it's easily to forget and mess that up when we reuse the component:

function App() {
  const [value, setValue] = React.useState('')

  function onChange(e, { ref }) {
    // ERROR --> e is actually the { ref } object so e.target is undefined
    setValue(e.target.value)
  }

  return (
    <div>
      <MyInput value={value} onChange={onChange} />
    </div>
  )
}

And it's also less stressful for you and other developers when we avoid this confusion.

A good example is when you want to allow the caller to provide as many event handlers as they want while enabling the app to behave normally:

const callAll = (...fns) => (arg) => fns.forEach((fn) => fn && fn(arg))

function MyInput({ value, onChange, onChange2, onChange3 }) {
  return (
    <input
      type="text"
      value={value}
      onChange={callAll(onChange, onChange2, onChang3)}
    />
  )
}

If at least one of them attempted to do some method that are specific to strings like .concat, an error would occur because the signature is that function(event, ...args) and not function(str, ...args):

function App() {
  const [value, setValue] = React.useState('')

  function onChange(e, { ref }) {
    console.log(`current state value: ${value}`)
    console.log(`incoming value: ${e.target.value}`)
    setValue(e.target.value)
    console.log(`current state value now: ${value}`)
  }

  function onChange2(e) {
    e.concat(['abc', {}, 500])
  }

  function onChange3(e) {
    console.log(e.target.value)
  }

  return (
    <div>
      <MyInput
        value={value}
        onChange={onChange}
        onChange2={onChange2}
        onChange3={onChange3}
      />
    </div>
  )
}

signature-change-event-handler-javascript-react

5. Avoid referencing and depending on state inside event handlers (Closures)

This is a really dangerous thing to do!

If done right, you should have no problems dealing with state in callback handlers. But if you slip at one point and it introduces silent bugs that are hard to debug, that's when the consequences begin to engulf that extra time out of your day that you wish you could take back.

If you're doing something like this:

function onChange(e, { ref }) {
  console.log(`current state value: ${value}`)
  console.log(`incoming value: ${e.target.value}`)
  setValue(e.target.value)
  console.log(`current state value now: ${value}`)
}

You should probably revisit these handlers and check if you're actually getting the right results you expect.

If our input has a value of "23" and we type another "3" on the keyboard, here's what the results say:

event-handler-unsynchronized-states

If you understand the execution context in JavaScript this makes no sense because the call to setValue had already finished executing before moving to the next line!

Well, that is actually still right. There's nothing that JavaScript is doing that is wrong right now. It's actually react doing its thing.

For a full explanation of the rendering process you can head over to their documentation.

But, in short, basically at the time whenever react enters a new render phase it takes a "snapshot" of everything that is present specific to that render phase. It's a phase where react essentially creates a tree of react elements, which represents the tree at that point in time.

By definition the call to setValue does cause a re-render, but that render phase is at a future point in time! This is why the state value is still 23 after the setValue had finished executing because the execution at that point in time is specific to that render, sorta like having their own little world that they live in.

This is how the concept of execution context looks like in JavaScript:

javascript-execution-context-diagram-flow-chart

This is react's render phase in our examples (You can think of this as react having their own execution context):

react-render-phase-reconciliation-flow-chart

With that said, let's take a look at our call to setCollapsed again:

updating-state-in-render-phase-in-react

updating-state-in-render-phase-in-react2

This is all happening in the same render phase so that is why collapsed is still true and person is being passed as null. When the entire component rerenders then the values in the next render phase will represent the values from the previous:

react-state-update-reconciliation2


Tags

javascript
composition
composing
react
event handler
handler
event
event handler
reusable
state management
state
callback

Subscribe to the Newsletter
Get continuous updates
© jsmanifest 2021