Useful Hooks For Your NextJS/React Project: useEventListener
React comes with several built-in hooks that cover a variety of use cases. But sometimes you need a hook for a specific purpose so that you can reuse shared logic throughout your applications.
In this article, which is the first in a series showcasing my favourite react hooks I use in every project, I will be showing you both how to use the useEventListener
hook and how to create your own from scratch.
And as an added bonus the full source code for the hook is provided at the end of the article.
Starting from scratch
Typically when you need to listen to an event in javascript you will need to add an event listener to the target that is publishing those events.
For example to listen for events on the window, document or another event target with pure javascript you would do this by calling
target.addEventListener(type, listenerCallback)
with the type of event and your callback function (in this case window
and 'keydown'
):
With that in mind you might conclude that you can just do the same from inside your react component:
But unfortunately you can't...
While this looks as though it should work at first glance, and it in fact might for some very basic use cases, you should not do this. There are a few hidden pitfalls with the code shown above:
- The listener could be added multiple times, such as each time the component renders adding multiple callbacks for the same event in the same component
- Once the component is unmounted the listener will still be registered, we should instead be diligent in cleaning up the event listener by removing it
We can instead achieve the desired effect by using the built-in useEffect
hook, the useEffect hook
allows for us to synchronize our component with an external system. In this case our "external system" is our event target of window
, so we can use
this hook to ensure our listener is added only once when the component is mounted and then using a clean-up function we can remove the listener when the
component unmounts.
useEffect(setup, dependencies?)
takes in both a setup function which should contain any setup code (along with the clean-up function which is returned from setup) as well as a list of dependencies that will cause the effect to be
executed again. So let's try adding that to our previous example:
In this example we call useEffect
with both our setup/clean-up function and an empty dependencies array. Our setup function first defines the callback we want to be called when an event happens
const callback = (e: Event) => { console.log('Key has been pressed!'); }
and then registers it with window as an event listener same as we did before window.addEventListener('keydown', callback)
.
The last thing we do in our setup function is return our clean-up function, the function we return here is called once the component is unmounted. In this example we are returning return () => window.removeEventListener('keydown', callback)
which will remove our event listener from the window. Finally we provide an empty array []
for the dependencies of the effect, this ensures that our effect only runs once when the component is first mounted.
While this approach works, it results in a lot of repeated code across components or even within the same component if you need to listen to multiple events. So luckily we have custom hooks to our rescue 🎉
Making our own custom hook
Now we understand the basics of using event handlers inside of useEffect
lets build our own custom hook to simplify our previous example component. Since this is the first part in my series of posts about custom hooks,
lets start at the beginning with what is a custom hook?
A custom hook allows us to extract custom logic into a separate function to combine/abstract multiple built-in hooks or other more complicated logic. By convention
react hooks should always start with use
followed by the name of the hook in pascal case, so for our usage we will call our hook useEventListener(...)
.
So lets start our hook by creating a new file I personally like to store my hooks in a separate folder src/hooks
and export a new function called useEventListener
which accepts three arguments;
targetElement
, type
and listener
(If you are using TypeScript don't worry about the type errors for now we will fix those later):
Now lets add the code that we used in our previous example but with a few tweaks:
- We now use
targetElement
andtype
in place ofwindow
and'keydown'
- We add
[targetElement, type]
to our dependencies array for ouruseEffect
call, this ensures that if these change in the component using our hook that we remove our current event listener and add a new event listener to the new target/type combination
You may have noticed our listener is missing and that was intentional to keep the changes small, so let's add that now.
To ensure that our event listener always has the latest value and to prevent unnecessary re-renders of our components we are going
to utilize another built-in react hook useRef. useRef
allows us to store a
reference to a value and access it's current value without affecting our useEffect
's dependency array.
To achieve this we add the following to our custom hook:
The above code initializes our ref with the initial value passed into listener and registers a second useEffect
to update our reference whenever the listener is changed.
We also added an arrow function const eventListener = (e) => listenerRef.current(e)
that calls our reference (this will be key for adding typing in the coming steps) which is
passed into both addEventListener
and removeEventListener
.
As a final finishing touch before we move onto typing our custom hook properly with TypeScript we can add a guard clause. This is to protect against the targetElement
being null
,
which can be super useful in Server Side Rendered React code where we might not always have access to the elements we want to add event listeners to. When running on the server we simply pass null
into targetElement
to skip registering the handlers and then once we have hydrated our content on the client we pass the actual element such as document
or window
.
This will prevent hydration errors in frameworks such as NextJS stopping your components from working in SSR.
If you are using javascript then the above is all you need to start using the hook and you can skip to using the hook but if you are using TypeScript (Psst you should be 😉) then continue reading to understand how to correctly type your new custom hook.
We will begin by making our hook into a generic function, this is because we want to accept multiple possible event targets.
We will accept a single generic type parameter K
for our type
parameter and add basic types to our other parameters:
Now this provides us with some limited typing but you might notice a few things. There is no limit on what can be provided via type
as we haven't
constrained the generic type K
and that our listener currently has the type (event: any) => void
as we don't know what event is being
listened to and thus can't properly type the event. We could naively type it as Event
same as we have done on line 19 and change to type: string
but we can do one step better than that.
We created our listener hook as a generic function for this exact reason, by defining a type containing all of the possible events we want to listen to which we will call EventType
. We
can then add a constraint to our generic function that will only allow events that exist in that type. Additionally we then have a type we can index into get the actual type of the event that we are listening to.
Our EventType
is a intersection of all of the event maps available for the event targets that we care about and we
can constrain our function to these types as shown below:
And that's all there is to it, if you have been following along then you have just created the useEventListener(targetElement, type, listener)
custom hook from scratch! 🥳🎉
How to use it
Now we have our custom hook you may ask "how do I use this in my components?", and that's easy you simply reference the hook in your imports, call it in your component and provide the required parameters:
Now isn't that much simpler to understand and it reduces code replication/complexity, what's not to love 😀
Full source code
As will always be the case the full source code for the custom hook we created in this post is available below and you can also see examples of it being used in action in the GitHub Repository for the blog you are reading right now!
Thanks for taking the time to read my post, I hope you enjoyed reading it! If you did I would greatly appreciate it if you shared it with your friends and colleagues.
Whether you did or you didn't I would love to hear your feedback; what works, what doesn't, did I leave anything out? Unfortunately I haven't implemented comments yet, but my socials are linked in the footer of this page if you wish to contact me.