Search code examples
javascriptreactjscallbackaddeventlistener

Why does binding variables to an event listener callback cause it to be defined again? Can this be avoided?


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!


Solution

  • 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>