Skip to content
Carl Victor Fontanos
Carl Victor Fontanos

Carl Victor Fontanos

Software Engineer

I build web applications and share what I learn along the way.

© 2026

jQuery and Vanilla JS Are Friends: The Interop Escape Hatches

C
Carlo Fontanos
· 2 min read

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.

C
Written by Carlo Fontanos

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.

Message sent!

Your details are only used to reply to you.

Keep reading