jQuery and Vanilla JS Are Friends: The Interop Escape Hatches
The migration advice you usually hear is a rewrite fantasy. The reality of a working product with a decade of jQuery: the two worlds will coexist for years, and the developers who thrive are the ones who move fluently between them rather than fighting a holy war. The border crossings, precisely:
Getting native elements out
const $panel = $('.panel');
$panel[0]; // first native element (undefined if empty - check!)
$panel.get(); // real array of all elements
[...$panel]; // spread works - jQuery objects are iterable
// Which unlocks every modern API on jQuery-selected elements:
$panel[0]?.scrollIntoView({ block: 'nearest' });
new ResizeObserver(onResize).observe($panel[0]);
$panel[0].animate(keyframes, 300);
That iterability is the underused one: for (const el of $items) loops native elements directly - no .each() callback contortions, and break/continue work.
Getting native things in
$(document.querySelectorAll('.card')); // NodeList -> jQuery, works fine
$(event.target).closest('.row'); // native event, jQuery traversal
$(fragment).appendTo('#list'); // DocumentFragments wrap too
$() accepts elements, arrays and NodeLists - so a modern module can hand results to legacy rendering code without ceremony.
The event bridge (this is the one that bites)
jQuery's .trigger() fires jQuery's handler registry - native addEventListener handlers for custom events won't hear it, and vice versa. Crossing the boundary requires native dispatch:
// New module announces; legacy jQuery listens - dispatch NATIVELY:
el.dispatchEvent(new CustomEvent('cart:updated', { bubbles: true, detail: { count } }));
$(document).on('cart:updated', (e) => updateBadge(e.originalEvent.detail.count));
// jQuery -> modern: same rule - if modern code must hear it, dispatchEvent, not .trigger()
Note the detail riding on e.originalEvent - jQuery wraps native events, and custom payloads live on the original. Standardizing on native CustomEvent for all cross-module communication (jQuery internals can keep .trigger for themselves) removes the whole class of "the event fires but nobody hears it" mysteries.
The migration strategy that actually completes
- New code: vanilla by default. The platform covers the old pain points - closest() for delegation, fetch, classList, element.animate.
- Migrate by module when touched, not by grep-and-replace across the codebase - half-translated files are worse than either dialect.
- Watch for the load-bearing plugins (datepickers, select2) - they, not $.ajax calls, are why jQuery stays. Replace those and the library often falls out on its own.
Treat jQuery as a legacy dialect you speak fluently rather than an enemy, and both the old code and the new code stay shippable - which is the actual job.
Full-stack web developer sharing practical tutorials and building tools that ship.
Got something on your mind?
My inbox is open - no forms disappearing into the void here.
- Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
- Hire me for a project Have something custom in mind? Let's talk scope and timelines.
- Product support Bought something here? I'll help you get it running.
I usually reply within 1-2 business days.