skip to Main Content

Apologies if this is a bit rough, I’m relatively new to React. This may even be more of a JavaScript question.

I’m trying to add an event listener that will trigger a callback inside a component. This component can appear on the page multiple times, and with the code below, when #btn is clicked the console.log will be output once – I can add as many <MyComponent /> as I like, and the log will only ever be output once – as required.

const callback = (e) => {
  console.log('callback happened!!', e.type);
}

const MyComponent = () => {
  const btn = document.getElementById('btn');
  if (btn) {
    const name = 'Bob';
    btn.addEventListener('click', callback);
  }
    return (
    <div>
      <p>Hi from my component!</p>
    </div>
  )
}

class App extends React.Component {
  constructor(props) {
    super(props)
  }
  
  render() {
    return (
      <div>
       <MyComponent />
       <p>...</p>
       <MyComponent />
      </div>
    )
  }
}

ReactDOM.render(<App />, document.querySelector("#app"))
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<div id="app"></div>
<div id="btn">button</div>

The problem I’m getting is if I try passing a variable (name) to the callback function using bind (eg btn.addEventListener('click', callback.bind(null, name));), when I click the button, the output will be logged twice – not what I want to happen! I need the name variable and the event object to be available in the callback function.

I should clarify that in the example above I am using a click listener on the button, but the real-world case will be listening for an event emitted from something else.

I have tried moving the callback function into the App class, and passing it as a prop to the component, but the same thing happens – as soon as I bind a variable to it, it triggers the console log twice.

So, my questions are, why does this happen? How can these requirements be achieved?

All suggestions welcome, and thanks!

2

Answers


  1. The problem here is that you are creating the event listener when your component gets created, but you’re never deleting it. This means that each time you instantiate the component, a new event listener is created, and so a new console.log will be displayed.

    Furthermore, you are doing things in a strange way:

    1. Don’t use React Class Components, Functional components are the recommended choice;
    2. Why are you creating the button with an id and then getting the button reference inside of the component? The advantage of React is the fact that you don’t have to do this, but you just insert the component where the <div id="btn">button</div> is.

    I will propose you two solutions to this problem, the first one using Class Components, in order to be closer to your solution (even though it’s not recommended to use them anymore) and one with functional components:

    Class Components:

    import React from "react"
    import ReactDOM from "react-dom"
    
    class MyComponent extends React.Component {
        handleClick = (name, e) => {
            console.log("callback happened!!", e.type, name)
        }
    
        render() {
            const { name } = this.props
            return (
                <div>
                    <p onClick={(e) => this.handleClick(name, e)}>Hi from my component!</p>
                </div>
            )
        }
    }
    
    class App extends React.Component {
        constructor(props) {
            super(props)
        }
    
        render() {
            return (
                <div>
                    <MyComponent name="Bob" />
                    <p>...</p>
                    <MyComponent name="Alice" />
                </div>
            )
        }
    }
    
    ReactDOM.render(<App />, document.querySelector("#app"))
    

    Functional Components:

    import React from "react"
    import ReactDOM from "react-dom"
    
    function MyComponent({ name }) {
        const handleClick = (e) => {
            console.log("callback happened!!", e.type, name)
        }
    
        return (
            <div>
                <p onClick={(e) => handleClick(e)}>Hi from my component!</p>
            </div>
        )
    }
    
    function App() {
        return (
            <div>
                <MyComponent name="Bob" />
                <p>...</p>
                <MyComponent name="Alice" />
            </div>
        )
    }
    
    ReactDOM.render(<App />, document.querySelector("#app"))
    

    Note that in both the solutions I don’t explicitly create the event listener, but I attach the callback function that has to be invoked on click inside the onClick property of the element.

    If, on the other hand, you are obliged to add the event listener, then you it’s important to remove it when the components unmount.

    Class Components:

    import React from 'react';
    import ReactDOM from 'react-dom';
    
    const callback = (name) => (e) => {
      console.log('callback happened!!', e.type, name);
    };
    
    class MyComponent extends React.Component {
      componentDidMount() {
        const btn = document.getElementById('btn');
        if (btn) {
          btn.addEventListener('click', callback(this.props.name));
        }
      }
    
      componentWillUnmount() {
        const btn = document.getElementById('btn');
        if (btn) {
          btn.removeEventListener('click', callback(this.props.name));
        }
      }
    
      render() {
        return (
          <div>
            <p>Hi from my component!</p>
          </div>
        );
      }
    }
    
    class App extends React.Component {
      constructor(props) {
        super(props);
      }
    
      render() {
        return (
          <div>
            <MyComponent name="Bob" />
            <p>...</p>
            <MyComponent name="Alice" />
          </div>
        );
      }
    }
    
    ReactDOM.render(<App />, document.querySelector('#app'));
    

    Functional Components:

    import React, { useEffect } from 'react';
    import ReactDOM from 'react-dom';
    
    const callback = (name) => (e) => {
      console.log('callback happened!!', e.type, name);
    };
    
    function MyComponent(props) {
      useEffect(() => {
        const btn = document.getElementById('btn');
        if (btn) {
          btn.addEventListener('click', callback(props.name));
    
          return () => {
            btn.removeEventListener('click', callback(props.name));
          };
        }
      }, [props.name]);
    
      return (
        <div>
          <p>Hi from my component!</p>
        </div>
      );
    }
    
    function App() {
      return (
        <div>
          <MyComponent name="Bob" />
          <p>...</p>
          <MyComponent name="Alice" />
        </div>
      );
    }
    
    ReactDOM.render(<App />, document.querySelector('#app'));
    

    Hope this is useful!

    Login or Signup to reply.
  2. You’ve said the button is a stand-in for a different kind of event source, so we won’t worry about the fact that attaching a click handler like this isn’t how you would normally do this in React. I’m going to assume the other event source is outside the React tree.

    Your component function is called every time the component needs to be rendered, which can be multiple times. Your code is adding a handler every time the function runs. When you do that using callback directly, it’s the same function every time so it doesn’t get added (because addEventListener doesn’t add the same function for the same event to the same event target more than once, even if you call it repeatedly). But when you use bind, you’re creating a new function every time, and so addEventListener adds those new functions on every render.

    Instead, do the set up only when the component is mounted. Also, remove it when the component is unmounted. You can do that via useEffect:

    const MyComponent = () => {
        useEffect(() => {
            const btn = document.getElementById("btn");
            if (btn) {
                const name = "Bob";
                const handler = callback.bind(null, name);
                // Or: `const handler = (event) => callback(name, event);`
                btn.addEventListener("click", handler);
                return () => {
                    // This function is called to clean up
                    btn.removeEventListener("click", handler);
                };
            }
        }, []); // <== Empty array means "only on mount"
        return (
            <div>
                <p>Hi from my component!</p>
            </div>
        );
    }
    
    const { useEffect } = React;
    
    const callback = (name, e) => {
        console.log(`Callback happened!! type = ${e.type}, name = ${name}`);
    };
    
    const MyComponent = ({ name }) => {
        useEffect(() => {
            console.log(`MyComponent "${name}": Mounted`);
            const btn = document.getElementById("btn");
            if (btn) {
                // Using a prop here instead of a constant so we can tell each
                // component instance is calling the callback
                const handler = callback.bind(null, name);
                // Or: `const handler = (event) => callback(name, event);`
                btn.addEventListener("click", handler);
                return () => {
                    // This function is called to clean up
                    btn.removeEventListener("click", handler);
                };
            }
        }, []); // <== Empty array means "only on mount"
        console.log(`MyComponent "${name}": Rendering`);
        return (
            <div>
                <p>Hi from my component! name = {name}</p>
            </div>
        );
    };
    
    // Note: The React team considedr `class` components "legacy;" new code should use function components
    class App extends React.Component {
        constructor(props) {
            super(props);
            this.state = {
                counter: 0,
            };
        }
    
        render() {
            const increment = () =>
                this.setState(({ counter }) => ({ counter: counter + 1 }));
            const { counter } = this.state;
            return (
                <div>
                    <div>
                        Click the counter to see that the handler isn't added on
                        every render: {counter}{" "}
                        <input type="button" value="+" onClick={increment} />
                    </div>
                    <MyComponent name="first" />
                    <p>...</p>
                    <MyComponent name="second" />
                </div>
            );
        }
    }
    
    ReactDOM.render(<App />, document.querySelector("#app"));
    <input type="button" id="btn" value="button">
    <div id="app"></div>
    
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search