Skip to content

Using Custom React Hooks to Listen to DOM Events

React
Watch the video

Don't want to read? Watch the video instead.

Listening to DOM events in React can be a little tricky and can quickly get messy. Even worse, it can be hard to trigger effects and state updates based on DOM events. Luckily, we can easily use custom hooks to 'reactify' our DOM event listeners. Read on!

Get Started

To get started, you're going to need a (surprise...) React app.

If you don't already have one, I recommend using create-react-app to quickly scaffold a new React app with all the tools you need for quick development.

Jump into your favourite terminal and run the command below. If you'd like to change the name of the folder your app is created in, change custom-react-hooks-dom-events to the folder name you'd like (this folder will be created).

npx create-react-app custom-react-hooks-dom-events

Open up the project in your favourite code editor. I'm using VS Code.

If you already have a React project, open that and get started.

Add a new hook

We're going to add a hook to listen to viewport width and changes. I'm going to put this code in a separate file so it's easy to reuse throughout the project.

Create a new file called useViewportWidth.js

I'm adding this file in src/useViewportWidth.js, but if you have a larger project, you'll probably want to have it somewhere more sensible.

Open up our new file and add the following code;

// src/useViewportWidth.js

// We'll need useState and useEffect from react
//This let's us 'reactify' our values
import { useState, useEffect } from "react";

// This is our custom hook
// It's just a function that returns a value for now
const useViewportWidth = () => {
  
  // Just a test
  const viewportWidth = "Viewport Width goes here"

  // Our hook should return something
  return viewportWidth
}

export default useViewportWidth

At this point this code is just a test to make sure our hook works.

Test that it works

I'm going to edit src/App.js for this demo, but you should open the component where you'd like to use the hook value.

// src/App.js

//
// ... other code
//

// Import our hook
import useViewportWidth from "./useViewportWidth";

function App() {
  // ... other code
  
  // Add our hook call
  // We're setting the result of our hook
  // to a constant called 'viewportWidth'
  const viewportWidth = useViewportWidth();


  // Render something
  return (
    <div className="App">
      <header className="App-header">
      {/* We're just going to render the viewport width for now.
      You could use this however you like */}
        <p>The viewport width is: {viewportWidth}</p>
      </header>
    </div>
  );
}

Here we imported the hook from the file we made earlier, and then called the hook within our component. Finally we rendered the result of the hook (viewportWidth) to the DOM.

If everything has gone well, you should see something like;

Make it useful

So far so good, but that's not really very useful yet. We need some information about the viewport.

We'll achieve this in a couple of steps.

  1. Add a function to get the width of the viewport
  2. Create a state object and setter via useState
  3. Bind an event listener to changes in the viewport size

Get the viewport width

We're going to add a function to get the width of the viewport. Now this could go in a separate file again if you want it to be reusable, but for simplicity I'm going to include it in the hook.

Update your src/useViewportWidth.js to look like this;

import { useState, useEffect } from "react";

const useViewportWidth = () => {
  
  // Get the viewport width
  const getViewportWidth = () => {
    let e = window,
      a = "inner";
    if (!("innerWidth" in window)) {
      a = "client";
      e = document.documentElement || document.body;
    }
    return e[a + "Width"];
  };

  return viewportWidth;
};
export default useViewportWidth;

Here we've added a function called getViewportWidth which does exactly what it says on the tin. It returns the width of the viewport (excluding scrollbars), and is reasonably cross browser compatible. It checks if window.innerWidth exists and if not uses document.clientWidth.

Add the viewport width to a state object

Remember how in React we need to add values into "state" if we want to perform actions (side-effects) based on their values? Side-effects might be two-way bindings, rendering etc.

So after our getViewportWidth function, we're going to add the following line.

const [viewportWidth, setViewportWidth] = useState(getViewportWidth())

What this does is sets up a state variable (viewportWidth) and sets it to the initial viewport width.

Bind and event listener to the viewport width

Finally, we need to add an event listener to listen to changes in the viewport width. We can do this via window.addEventListener, but there are a couple of things we need to do to 'reactify' it.

We're going to add a useEffect hook, just after the last line we added (useState) to run a side-effect when the component mounts.

// Run an effect when the component mounts
useEffect(() => {
  // We're going to create an 'onResize' event handler which will update our state
  const setFromEvent = () => setViewportWidth(getViewportWidth());
  
  // Add an event listener for resize, which will update our state
  window.addEventListener('resize', setFromEvent)
  
  //Finally, remember to unbind the event listener on unmount
  return () => {
    window.removeEventListner('resize', setFromEvent)
  }
}, []); // Empty parentheses will cause this to run once at mount

Our useEffect hook only runs once on component mount, and adds an event listener to the window resize event. The event listener sets our state variable to the new size of the viewport. Finally, we return a function to be called on unmount which will tidy up and remove the event listener.

Here's one I prepared earlier

If you put it all together correctly, your src/useViewportWidth.js file should look like this;

You can feel free to copy/paste this into your project if you want.

import { useState, useEffect } from "react";

const useViewportWidth = () => {
  const getViewportWidth = () => {
    let e = window,
      a = "inner";
    if (!("innerWidth" in window)) {
      a = "client";
      e = document.documentElement || document.body;
    }
    return e[a + "Width"];
  };

  const [viewportWidth, setViewportWidth] = useState(getViewportWidth());

  useEffect(() => {
    const setFromEvent = () => setViewportWidth(getViewportWidth());

    window.addEventListener("resize", setFromEvent);

    return () => {
      window.removeEventListener("resize", setFromEvent);
    };
  }, []);

  return viewportWidth;
};
export default useViewportWidth;

And if you save and switch to your app should should see;

Wrapping up

Awesome, you should now have seen how we can use a custom React hook to bind to a DOM event. See if you can use this to listen to the scroll position (scroll top) instead. Hit me up on twitter if you have an questions, feedback or suggestions, I'd love to hear them. I'm @baffledbasti on twitter.

Before you go...

One last thing before you go. You might have noticed that our custom React hook will fire on every single update of the DOM event we're listening to. Consequently, any side-effects or renders that result from those updates will also run every single time. This might be many times per second! If you're using this in production it could have significant performance implications.

One thing we can do to this code to make it have a slightly smaller performance impact is throttle our hook updates.

The idea behind throttling is that we only allow an event to occur once per some period of time. Any additional triggers in this period are ignored.

Below is an example of how we can throttle our hook updates using the throttle function from the lodash library. You may not want to include lodash for just this function (although with tree shaking you can get around that).

Two (and a bit) steps to throttling

1. Import our throttle function

It's not really a step, but import the throttle function from lodash like so

// Only import the throttle function
// this way tree shaking can only include this function
import {throttle} from 'lodash'

2. Create a throttled version of our set state function

The throttle function from lodash works by taking a function and returning a throttled version. (You can read the lodash docs about throttle function if you like).

Remember our function we created to pass to the window.addEventListener?

We're going to create a throttled version.


// ...
// ...

// This is unchanged
const setFromEvent = () => setViewportWidth(getViewportWidth());

// Create a throttled version
// that only fires every 100ms
const throttledSet = throttle(setFromEvent, 100, {
  leading: true,
  trailing: true
});

We created a throttled version of our function (called throttledSet) that only fires every 100ms. We also specified that we want it to fire on the leading edge and the trailing edge.

If you're interested in understanding throttling and what the leading and trailing edge triggering are about, check out our post Understanding event throttling.

3. Update our event binding

Finally, change your event bindings to call our new throttled function instead of the old one.'

useEffect(() => {
  
  // ...
  
  window.addEventListener('resize', throttledSet);
  return () => {
    window.removeEventListener('resize', throttledSet);
  }
  
})

If you save and run your code again, you'll notice the viewportWidth is only updated at most twice per 100ms. Of course this may be hard to see, so if you'd like to see it with your own eyes, try setting the throttle window to 1000ms (1 second) and see what happens.