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