April 20: Intervals and event listeners when working with React hooks
One reason React is so widely used for building JavaScript user interfaces is its declarative design philosophy — engineers can focus on writing code without the hassle of interacting directly with the document. This power has made React very popular… but it also turns other important parts of JavaScript, like event listeners and intervals, into a tiresome chore.
In this blog post (my first as a Flatiron graduate!), I’ll take a deep dive into declarative programming with React, and how to make the most of it without losing what we all know and love about pure JavaScript. To demonstrate, I’ll use code from a React adaptation of a word-matching game I shared on a previous blog post, which includes a timer and keyboard events.
Background: Boogie with the Hook
I’ll assume you’re already familiar with React if you’re reading this, but nevertheless I’ll start with an insultingly brief overview: it’s an open-source JavaScript library for building user interfaces created by a Facebook software engineer and first deployed on Facebook products beginning in 2011–12. The building blocks of React code are components: closures which return some markup code (like HTML or XML). Components can make use of external variables or functions called props, like the example <Timer />
component below, which accepts a currentTime
prop and then formats/displays it:
export default function Timer( { currentTime } ) { return <div className="timer">
<span>{ Math.floor( currentTime / 60 ) }</span>
:
<span>{ ( "0" + ( currentTime % 60 ) ).slice( -2 ) }</span>
</div>;}
While the details are inconsequential, React is quick and efficient because it reconciles (renders or updates) the browser’s displayed DOM using an in-memory cache, allowing the engineer to write code as if the entire page is rendered, even though React is only rendering components that change.
React 16.8 introduced a feature called hooks. Hooking is a very broad and important topic in computer science, but in the context of React, hooks allow an engineer to “hook” into attributes or behaviors of a component (its state or lifecycle) without writing a class. This article will only address the use of listeners/intervals in React with hooks, but please leave a comment if you’d like to see an addendum addressing this problem in older class-based React code!
Sounds great…but there’s a catch, right?
For programmers accustomed to writing procedural code, React can cause a great deal of hand-wringing anguish. Declarative code isn’t synchronous like pure JavaScript, which runs in a (relatively) straightforward manner from beginning to end, so a programmer must adjust his thought process and strategy accordingly.
Let’s examine the timer example above, which obviously should tick away and update itself as time passes. The self-evident approach to implement a timer in JavaScript is setInterval
… why not give that a try in React? Let’s move the currentTime
prop to a state in this component with the useState()
hook, then use setInterval
to update the time every second:
import { useState } from "react";export default function Timer() { const [ currentTime, setCurrentTime ] = useState( 240 ); function tick() {
if ( !!currentTime ) setCurrentTime( currentTime - 1 );
} setInterval( tick, 1000 ); return <div className="timer">
<span>{ Math.floor( currentTime / 60 ) }</span>
:
<span>{ ( "0" + ( currentTime % 60 ) ).slice( -2 ) }</span>
</div>;}
Unfortunately, when we do that, what we see on the page isn’t much of a timer at all! Even though we’ve setInterval
to tick
only once every 1,000 milliseconds, our timer is ploughing forward like a runaway freight train!
The reason why touches upon the declarative, asynchronous nature of React and its relationship with the DOM I explained above. Remember that React reconciles the DOM whenever one of its constituent components changes. In this case, the <Timer />
creates a setInterval
upon each render, but the creation of this setInterval
changes the component and causes a re-render… which triggers the creation of another setInterval
… and another re-render… and so on and so forth until the end of the universe.
A similar problem can be seen with event listeners, which we use in pure JavaScript to respond to clicks, key presses and all the other user behaviors that make web apps so delightful. In my word-matching game, the main <App />
component contains a keyboard event listener to allow a player to enter their guess:
export default function App() {...function handleKeyPress( keyPressEvent ) { ... }window.addEventListener( "keydown", handleKeyPress );return <div className="app">
...
</div>;}
But when firing up my app in the browser, I encounter the same problem! The <App />
will addEventListener
when rendered — changing the component whenever a key is pressed and causing an endless downward spiral of re-renders and event listeners that overwhelms the DOM, crashes the app and sends poor Josh to his therapist’s office.
Still living with the side effects
I’ll demonstrate an approach to solving this problem with two very important React hooks: useEffect
and useCallback
.
Remember above I said that hooks address attributes or behaviors of the component they’re called upon. In this case, our timer and app are behaving strangely due to their lifecycle — bad things are happening because these components are re-rendering. useEffect
and useCallback
“hook” into that lifecycle and give us the ability to force our components to do something or behave a certain way every time they’re mounted or unmounted.
Both the useEffect
and useCallback
hooks take two arguments:
- a callback function defining the behavior to hook; and
- an array of dependencies: functions or variables within the component’s scope which may change as a result of the hook’s execution
Let’s start with our <Timer />
and add a useEffect
to fix our interval problem:
import { useEffect, useState } from 'react';export default function Timer() {const [ currentTime, setCurrentTime ] = useState( 240 );function tick() {
if ( !!currentTime ) setCurrentTime( currentTime - 1 );
}useEffect( () => {
const timer = setInterval( tick, 1000 );
return () => clearInterval( timer );
}, [ tick ] );return <div className="timer">
<span>{ Math.floor( currentTime / 60 ) }</span>
:
<span>{ ( "0" + ( currentTime % 60 ) ).slice( -2 ) }</span>
</div>;}
Two very important notes:
- The
tick()
function is included as a dependency of theuseEffect
hook, because its use creates a side effect that changes the component’s state (hence the name); - The
useEffect
mustreturn
a function that callsclearInterval
, so a newtick()
interval isn’t created upon every re-render.
Now, at long last, our timer ticks once a second! The <Timer />
will still unmount and remount (as it should), but thanks to our hooks, it’ll only do so when the DOM actually needs to be updated and not when an interval side effect is created. But don’t celebrate quite yet — behind the scenes React will flash an interesting warning message:
The ‘tick’ function makes the dependencies of useEffect Hook (at line 7) change on every render. Move it inside the useEffect callback. Alternatively, wrap the definition of ‘tick’ in its own useCallback() Hook
Okay, well, that’s a great deal more helpful than your average JavaScript error message, but… what’s going on here? Just like the message says, React will create a new instance of the tick()
function every time the <Timer />
component is re-mounted… and while this won’t necessarily break the app, it’s a grossly inefficient use of memory that’s bad for performance and definitely to be avoided. So, let’s follow the compiler’s advice and wrap tick()
in a useCallback
hook:
import { useCallback, useEffect, useState } from 'react';export default function Timer() {const [ currentTime, setCurrentTime ] = useState( 240 );const tick = useCallback( () => {
if ( !!currentTime ) setCurrentTime( currentTime - 1 );
}, [ currentTime, setCurrentTime ] );useEffect( () => {
const timer = setInterval( tick, 1000 );
return () => clearInterval( timer );
}, [ tick ] );return <div className="timer">
<span>{ Math.floor( currentTime / 60 ) }</span>
:
<span>{ ( "0" + ( currentTime % 60 ) ).slice( -2 ) }</span>
</div>;}
We redefine tick
as a useCallback
, and just like useEffect
, we include currentTime
and setCurrentTime
as dependencies — ensuring that tick
will never multiply and waste memory!
We’ll use the exact same approach with our event listener in <App />
—but return
ing a function that calls removeEventListener
:
import { useCallback, useEffect, useState } from 'react';export default function App() {...const handleKeyPress = useCallback( keyPressEvent => {
...
}, [ ... ] );useEffect( () => {
window.addEventListener( "keydown", handleKeyPress );
return () => window.removeEventListener( "keydown", handleKeyPress );
}, [ handleKeyPress ] );return <div className="app">
...
</div>;}
Conclusion
React’s quirky react-ion to intervals and event listeners is such a common source of frustration that a league of custom hooks have popped up to help programmers write them declaratively. But the many useEventListener
or useInterval
custom hooks found on StackOverflow aren’t strictly necessary or desirable. Now that you have a fuller understanding of the purpose React hooks really serve, you’ll find it much easier to give your apps the robust functionality your users expect— declaratively, the React way!