Why Bootstrap Modal shown.bs.modal Fires Twice in React useEffect
Learn why Bootstrap 'shown.bs.modal' event fires twice in React useEffect (empty deps, no StrictMode) but once in plain JS. Fixes include cleanup, ref guards, and react bootstrap alternatives for reliable modal events.
Why does the Bootstrap ‘shown.bs.modal’ event fire twice when adding an event listener via addEventListener in a React useEffect hook (with empty dependency array and StrictMode disabled), but only fire once in plain HTML/JavaScript?
The shown.bs.modal event fires twice in React’s useEffect with an empty dependency array because the listener attaches multiple times—often due to component re-mounts, re-renders, or incomplete cleanup—without StrictMode’s intentional double-invocation. In plain HTML/JavaScript, it attaches once statically outside any lifecycle, firing reliably on the single modal show. Fixing this requires removeEventListener in cleanup or ref-based guards to mimic vanilla JS behavior.
Contents
- Why useEffect Runs Twice with Empty Dependency Array in React
- Understanding Bootstrap Modal Events Like shown.bs.modal
- Attaching Event Listeners to Bootstrap Modal in React Components
- Common Pitfalls: Multiple Event Firings in useEffect vs. Plain JavaScript
- Fixing Double Fires – Cleanup and Guards in useEffect
- React Bootstrap Modal Specifics and Alternatives
- Testing and Best Practices for Modal Event Handling
- Sources
- Conclusion
Why useEffect Runs Twice with Empty Dependency Array in React
Ever wondered why your useEffect callback seems stubborn, executing more than once despite that empty [] array? It’s not always StrictMode’s fault—though disabling it helps. React components can re-mount entirely if a parent re-renders with a new key, or if the DOM element you’re targeting (like a bootstrap modal) isn’t stable yet.
Take this common setup:
useEffect(() => {
const modal = document.getElementById('myModal');
modal.addEventListener('shown.bs.modal', handleShown);
}, []); // Empty deps, StrictMode off
Here, useEffect should run once on mount. But if the component unmounts and remounts subtly—say, from a route change or conditional render—the listener stacks up. No cleanup? Boom, two (or more) handlers for shown.bs.modal. Developers on Stack Overflow nailed it: even without StrictMode, React’s reconciliation can trigger extras if your modal lives outside the component or gets re-hydrated.
And it’s worse in dev mode. Production builds it once, sure. But test thoroughly—I’ve seen lists grow to three or four without guards.
Understanding Bootstrap Modal Events Like shown.bs.modal
Bootstrap’s shown.bs.modal is straightforward in vanilla JS: it bubbles once after CSS transitions finish on the modal element. Attach it like element.addEventListener('shown.bs.modal', callback), and you’re golden—one fire per show.
From the official Bootstrap docs, this event targets the modal <div> directly, not the document. Miss that, and you might chase ghosts. In plain HTML:
<script>
const modal = document.getElementById('myModal');
modal.addEventListener('shown.bs.modal', () => console.log('Shown!'));
// Fires exactly once on modal('show')
</script>
No doubles here because nothing re-attaches the listener. Bootstrap doesn’t dispatch extras unless you hammer .modal('show') redundantly, as noted in this Stack Overflow thread. But port it to React useEffect? Suddenly multiples. Why? Lifecycle mismatches.
Attaching Event Listeners to Bootstrap Modal in React Components
React loves controlled components, but Bootstrap modals are DOM-heavy. You grab the element via document.getElementById inside useEffect, add the listener for shown.bs.modal, and pray. Works-ish, until re-renders bite.
Common pattern:
const MyComponent = () => {
useEffect(() => {
const modalEl = document.getElementById('myModal');
if (modalEl) {
modalEl.addEventListener('shown.bs.modal', () => {
console.log('Modal shown'); // Logs twice!
});
}
}, []);
return <div id="myModal" className="modal">...</div>;
};
The if check helps, but skips the real issue: no teardown. Component updates? Listener lingers. New mount? Another piles on. In react bootstrap setups, this amplifies because wrappers like <Modal> handle state internally, but custom addEventListener ignores it.
Contrast with plain JS—no React fiber commits to interrupt.
Common Pitfalls: Multiple Event Firings in useEffect vs. Plain JavaScript
Here’s the crux: vanilla JS attaches listeners once, outside any loop. HTML loads, script runs, addEventListener sticks forever. Modal shows? Single shown.bs.modal ping.
React useEffect? It’s a hook, not a script tag. Empty deps mean “run on mount,” but:
- Parent re-renders with
keychange → remount → double attach. - Modal in a portal or conditional → DOM flutters → extra runs.
- No
removeEventListener→ accumulation on open/close cycles.
Stack Overflow devs saw this with modals reopening: each cycle adds handlers without off(). In React, useEffect without cleanup does the same. Even StrictMode off, subtle re-executions happen from state lifts or HOCs.
Plain JS wins simplicity. React demands discipline.
Fixing Double Fires – Cleanup and Guards in useEffect
Ready to stop the madness? First, cleanup:
useEffect(() => {
const modalEl = document.getElementById('myModal');
if (!modalEl) return;
const handleShown = () => console.log('Shown once!');
modalEl.addEventListener('shown.bs.modal', handleShown);
return () => modalEl.removeEventListener('shown.bs.modal', handleShown);
}, []);
This tears down on unmount. Still doubles? Add a ref guard:
const hasRun = useRef(false);
useEffect(() => {
if (hasRun.current) return;
hasRun.current = true;
// Attach listener...
}, []);
Or Bootstrap’s { once: true }:
modalEl.addEventListener('shown.bs.modal', handleShown, { once: true });
Fires once total, like jQuery .one(). Matches plain JS perfectly. Experts recommend refs for idempotency—prevents stacks even on remounts.
Test it. You’ll sleep better.
React Bootstrap Modal Specifics and Alternatives
react bootstrap shines for modals—no raw DOM hacking. <Modal show={isOpen} onEntered={() => console.log('Entered!')}> mimics shown.bs.modal via onEntered, firing once post-transition.
But if you’re mixing native Bootstrap JS? Doubles galore, as this thread explains: internal transitions trigger re-renders, re-running useEffect. Ditch custom listeners; use props.
Alternatives:
- Native Bootstrap: Stick to
data-bs-toggle="modal"buttons. - React Bootstrap: Full control, no
addEventListenerwoes. - Headless UI or Radix: Pure React, zero jQuery ghosts.
Why force bootstrap modal into hooks when wrappers exist?
Testing and Best Practices for Modal Event Handling
Don’t guess—use React Testing Library. Mock the modal, fire shows, assert single logs:
test('shown event fires once', () => {
render(<MyComponent />);
fireEvent.click(screen.getByText('Open Modal'));
expect(mockShown).toHaveBeenCalledTimes(1);
});
Best practices:
- Always cleanup listeners.
- Prefer component-scoped refs over globals.
- Memoize handlers:
useCallbackavoids recreation. - Debug with
console.trace()in callbacks—spot multiples fast. - Production check: Builds hide dev quirks, but tests catch 'em.
For bootstrap 5 modal, enable {once: true} liberally. Your future self thanks you.
Sources
- useEffect is called twice even if an empty array is used as an argument — Explains React useEffect double execution causes and fixes: https://stackoverflow.com/questions/60618844/useeffect-is-called-twice-even-if-an-empty-array-is-used-as-an-argument
- shown.bs.modal fires multiple times when you close and reopen modal — Details Bootstrap modal event listener accumulation on reopen: https://stackoverflow.com/questions/21015719/shown-bs-modal-fires-multiple-times-when-you-close-and-reopen-modal
- Bootstrap modal firing show event twice — Covers redundant Bootstrap modal show calls causing extra events: https://stackoverflow.com/questions/42124403/bootstrap-modal-firing-show-event-twice
- Bootstrap Modal Documentation — Official guide to Bootstrap 5 modal events and addEventListener usage: https://getbootstrap.com/docs/5.3/components/modal/
- React-Bootstrap Modal rendering twice — React Bootstrap Modal re-render issues leading to duplicate effects: https://stackoverflow.com/questions/46798963/react-bootstrap-modal-rendering-twice
Conclusion
The double shown.bs.modal fire boils down to React useEffect listener accumulation versus plain JS’s one-and-done attachment—cleanup with removeEventListener or {once: true} fixes it every time. Lean on react bootstrap wrappers for sanity, test rigorously, and you’ll avoid these headaches. Solid modals ahead.
In React development mode, useEffect with an empty dependency array [] often runs twice due to StrictMode double-invoking effects to detect side effects, even if explicitly disabled—check index.js and remove <React.StrictMode>. Component remounting from parent keys or multiple instances can also cause re-execution, leading to duplicate addEventListener calls for events like shown.bs.modal. Use a ref guard (e.g., useRef(false)) or cleanup with removeEventListener to prevent multiple firings; in production, it runs once. For React Bootstrap modal integrations, verify no higher-tree unmounts trigger extra useEffect runs.
The shown.bs.modal event fires multiple times because addEventListener (or .on() in jQuery) accumulates handlers without removal—each modal open or component re-render adds another listener. In plain HTML/JavaScript with Bootstrap modal, a single attachment outside click handlers fires once, but in React useEffect, double execution attaches twice. Fix by using .one('shown.bs.modal') for single-fire, off() before re-on(), or return removeEventListener from useEffect cleanup; extract listener setup outside open functions to avoid multiples on reopen.
Bootstrap modal show events like shown.bs.modal can fire twice if calling .modal(options) followed by .modal('show'), redundantly triggering internal state changes. In React with useEffect and React Bootstrap, avoid manual .modal('show')—use data-toggle/data-target attributes on buttons for standard single-fire behavior. This matches plain JS where proper Bootstrap usage (no redundant calls) ensures modal events fire once, preventing doubles from improper integration.
Bootstrap 5 modal events like shown.bs.modal fire once at the modal div after CSS transitions complete; attach via addEventListener('shown.bs.modal', handler) on the element (e.g., document.getElementById('myModal')). In vanilla JS, this fires reliably once per show, but React useEffect re-attachment without cleanup causes multiples. Always target the modal element directly for Bootstrap modal JS events, with optional {once: true} to mimic jQuery .one().
React Bootstrap Modal renders twice due to internal state updates on enter (via react-transition-group onEntering), triggering re-renders even with initial show={true}, which can lead to duplicate useEffect event listeners for shown.bs.modal. In plain HTML/JS, no such state causes single fires, but React’s lifecycle amplifies it. Use refs or memoization to stabilize; for custom Bootstrap modal in React, prefer native Bootstrap over React Bootstrap wrappers to avoid transition-induced doubles.