5 Critical Tips for Composing Event Handler Functions in React
jsmanifest
Posted on May 23, 2020
Find me on medium
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:
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>
)
}
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:
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:
This is react's render phase in our examples (You can think of this as react having their own execution context):
With that said, let's take a look at our call to setCollapsed
again:
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:
Find me on medium
Posted on May 23, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
October 7, 2024