Enterprise migrations rarely happen overnight. While you are architecting a modern React 19 frontend, the business logic relies on a battle-hardened DataTables implementation or a highly customized Select2 dropdown from 2016. Rewriting these complex libraries from scratch is often a resource black hole.
The alternative is wrapping them. However, naively dumping a jQuery plugin into a useEffect leads to race conditions, zombie event listeners, and the dreaded "NotFoundError: Node was not found" when React tries to reconcile a DOM tree that jQuery has ruthlessly mutated.
The Root Cause: The Battle for DOM Supremacy
The core conflict arises from the ownership model of the Document Object Model (DOM).
- React's Assumption: React operates on the Virtual DOM (Fiber). It calculates diffs and assumes exclusive rights to update the DOM based on state changes.
- jQuery's Assumption: jQuery operates directly on the Real DOM. It injects elements, modifies class names, and attaches native event listeners immediately.
When you initialize a plugin like Select2, it often hides the original <select> element and injects a complex div structure next to it. If React re-renders the parent component, it compares its Virtual DOM (which only sees the <select>) against the Real DOM (which now contains jQuery's injected soup).
In React 19 Strict Mode, components mount, unmount, and remount immediately during development to flush out side effects. If your cleanup logic is flawed, you will initialize the plugin twice on the same node, causing memory leaks and UI duplication.
The Fix: The "Black Box" Pattern with Ref Stability
To solve this, we treat the jQuery container as a "Black Box." React renders a container element but never touches its children after the initial mount. We use useRef to maintain a handle on the DOM node and the plugin instance, bypassing React's reconciliation flow for the legacy portion.
Here is a production-ready implementation of a Select2 wrapper using TypeScript and React 19.
The Implementation
import React, { useEffect, useRef, memo } from 'react';
import $ from 'jquery';
import 'select2';
import 'select2/dist/css/select2.css';
interface Select2Props {
value: string;
options: { id: string; text: string }[];
onChange: (value: string) => void;
width?: string;
placeholder?: string;
disabled?: boolean;
}
/**
* A safe wrapper for Select2 that handles Strict Mode lifecycles
* and manual event bridging.
*/
const Select2Wrapper = ({
value,
options,
onChange,
width = '100%',
placeholder = 'Select an option',
disabled = false
}: Select2Props) => {
// 1. Ref to hold the DOM element React renders
const selectRef = useRef<HTMLSelectElement>(null);
// 2. Ref to guard against race conditions during rapid unmounts
const isMounted = useRef(false);
// 3. Initialize and Destroy Logic
useEffect(() => {
isMounted.current = true;
const $el = $(selectRef.current!);
// Initialize Select2
$el.select2({
data: options,
width: width,
placeholder: placeholder,
// Prevent Select2 from creating a new dropdown container in body
// if not strictly necessary, keeping DOM locality better.
dropdownParent: $el.parent()
});
// 4. Bridge jQuery Events to React
// We must manually listen to jQuery's event and trigger the React prop
$el.on('select2:select', (e: any) => {
const selectedValue = e.params.data.id;
// Prevent loop if value is already same
if (isMounted.current) {
onChange(selectedValue);
}
});
// Unselect event handling
$el.on('select2:unselect', () => {
if (isMounted.current) {
onChange('');
}
});
// 5. Cleanup Phase (Critical for React 19 Strict Mode)
return () => {
isMounted.current = false;
// Remove event listeners specifically attached by us
$el.off('select2:select');
$el.off('select2:unselect');
// Destroy the plugin instance to revert DOM mutations
// Checks if select2 is initialized to avoid errors
if ($el.hasClass('select2-hidden-accessible')) {
$el.select2('destroy');
}
};
// Dependencies: Only re-run if options structure physically changes.
// We do NOT include 'value' here to avoid destroying/recreating on every selection.
}, [JSON.stringify(options), width, placeholder]);
// 6. Reactive Updates
// If the parent updates the value prop (e.g. form reset),
// we manually update the jQuery instance without re-initializing.
useEffect(() => {
const $el = $(selectRef.current!);
const currentValue = $el.val();
if (currentValue !== value) {
$el.val(value).trigger('change');
}
// Handle disabled state dynamically
$el.prop('disabled', disabled);
}, [value, disabled]);
// 7. Render a clean container.
// React controls the <select>, jQuery mutates it post-render.
return <select ref={selectRef} />;
};
// Use memo to prevent re-renders if parent state changes unrelated props
export default memo(Select2Wrapper);
Why This Works
1. Bifurcated Lifecycle Management
We separated the Initialization (creating the instance) from the Synchronization (updating values).
- The first
useEffectdepends onoptions. It only runs when the structural data changes. It handles the expensive DOM injection. - The second
useEffectdepends onvalueanddisabled. It performs cheap operations (.val(),.trigger()) to keep the visual display in sync with React state without tearing down the plugin.
2. Manual Event Bridging
jQuery events do not bubble into React's Synthetic Event system automatically.
- We use
$el.on('select2:select', ...)to catch the specific library event. - We immediately call the
onChangeprop passed from the React parent. This makes the component behave like a controlled React input from the outside, hiding the jQuery implementation details.
3. Aggressive Cleanup (The React 19 Requirement)
In React 19 Strict Mode, the component effectively does this: Mount -> Unmount -> Mount. Without the cleanup function in the first useEffect, the following happens:
- Mount 1: Select2 initializes.
- Unmount 1: Nothing happens (if cleanup is missing). DOM is still mutated.
- Mount 2: Select2 tries to initialize on an element that already has Select2 classes. It creates a nested, broken UI or crashes.
Our cleanup explicitly calls $el.select2('destroy'). This reverts the DOM to a plain <select> element, ensuring the second mount starts with a clean slate.
4. The isMounted Guard
Asynchronous updates or rapid route changes can trigger state updates on unmounted components. While React handles this better now, legacy jQuery callbacks might still fire after the component has unmounted. The isMounted ref prevents us from calling onChange if the component is in the process of tearing down.
Conclusion
You do not need to rewrite your entire legacy codebase to use React 19. By respecting the boundary between React's Virtual DOM and jQuery's Direct DOM manipulation—and rigorously handling cleanup phases—you can wrap complex legacy plugins safely. Treat them as unmanaged external systems, sync data via useEffect, and always clean up your mess on unmount.