Migrating off jQuery to vanilla JavaScript

An idiom-by-idiom translation table, how to use jQuery Migrate to find what breaks, and a migration order that ships incrementally.

The translation table

jQueryVanillaNote
$('#id')document.getElementById('id')Faster; returns one element or null
$('.cls')document.querySelectorAll('.cls')Returns a static NodeList
$el.find('.x')el.querySelectorAll('.x')Scoped to the element
$el.closest('.p')el.closest('.p')Identical semantics
$el.addClass('a')el.classList.add('a')Also remove, toggle, contains
$el.attr('x')el.getAttribute('x')
$el.prop('checked')el.checkedDirect property access
$el.val()el.valueFor a multi-select, map selectedOptions
$el.text() / .html()el.textContent / el.innerHTML
$el.css('color')getComputedStyle(el).colorReads the resolved value
$el.css('color', 'red')el.style.color = 'red'Writes an inline style
$el.on('click', fn)el.addEventListener('click', fn)Pass { once: true } for one-shot
$el.off('click', fn)el.removeEventListener('click', fn)The function reference must match
$parent.on('c', '.child', fn)parent.addEventListener('c', e => { const t = e.target.closest('.child'); if (t) fn.call(t, e); })Delegation by hand
$el.append(x)el.append(x)Accepts strings and nodes
$el.remove()el.remove()
$el.clone()el.cloneNode(true)The true means deep
$(fn)document.addEventListener('DOMContentLoaded', fn)Or place the script with defer
$.extend({}, a, b){ ...a, ...b }Deep merge needs a helper
$.each(list, fn)list.forEach(fn)
$.ajax()fetch()Check res.ok; parse with .json()
$el.animate()Web Animations API or CSS transitionsPrefer CSS
$el.fadeIn()el.animate([{opacity:0},{opacity:1}], 200)Or a transition class
// A delegation helper replaces the most valuable jQuery idiom
function delegate(root, type, selector, handler) {
  root.addEventListener(type, (event) => {
    const target = event.target.closest(selector);
    if (target && root.contains(target)) handler.call(target, event, target);
  });
}

delegate(document.getElementById('tbody'), 'click', '.delete', function (event) {
  event.preventDefault();
  deleteRow(this.closest('tr').dataset.id);
});

// A tiny DOM builder replaces string-concatenated HTML
function el(tag, props = {}, children = []) {
  const node = document.createElement(tag);
  for (const [key, value] of Object.entries(props)) {
    if (key === 'class') node.className = value;
    else if (key === 'text') node.textContent = value;
    else node.setAttribute(key, value);
  }
  children.forEach((child) => node.append(child));
  return node;
}

const row = el('tr', { class: 'row', 'data-id': item.id }, [
  el('td', { text: item.name }),
  el('td', { text: item.total })
]);
⚠️
The one behavioural difference that catches everyone: querySelectorAll returns a static NodeList snapshot, while getElementsByClassName returns a live HTMLCollection that changes as the DOM changes. Iterating a live collection while removing elements skips items.

Running the migration

<!-- jQuery Migrate logs deprecations to the console. Use it to find what breaks,
     then remove it. It is a diagnostic tool, not a runtime dependency. -->
<script src="/js/jquery-3.7.1.min.js"></script>
<script src="/js/jquery-migrate-3.5.2.min.js"></script>
  1. Add jQuery Migrate to the development build only and fix everything it warns about. That makes the jQuery 4 upgrade mechanical before you remove jQuery at all.
  2. Introduce the delegation and DOM builder helpers above. They let new code be jQuery-free while old code keeps working, with no big-bang rewrite.
  3. Convert one module at a time, starting with the leaf modules that have the fewest imports and the best tests. Each conversion ends with that module importing nothing from jQuery.
  4. Replace $.ajax with fetch next. It is mechanical, and it removes the largest jQuery module from the bundle, which makes the slim build viable.
  5. Replace animation last. It is the change with the most visual risk and the least functional benefit.
  6. Measure: run the bundle analyser before and after each step, and keep a snapshot of the compiled byte total in the repository.
// A before/after that shows the real shape of the change
// BEFORE — assumes a jQuery object and a chain
// function renderInbox(rows) {
//   const $tbody = $('#tbody').empty();
//   rows.forEach((row) => {
//     $('<tr>')
//       .addClass('row').attr('data-id', row.id)
//       .append($('<td>').text(row.subject))
//       .append($('<td>').text(row.total))
//       .appendTo($tbody);
//   });
//   $('#count').text(rows.length + ' messages');
// }

// AFTER — DOM APIs, with the same behaviour and no dependency
function renderInbox(rows) {
  const tbody = document.getElementById('tbody');
  tbody.replaceChildren(...rows.map((row) =>
    el('tr', { class: 'row', 'data-id': row.id }, [
      el('td', { text: row.subject }),
      el('td', { text: row.total })
    ])
  ));
  document.getElementById('count').textContent = rows.length + ' messages';
}
StepTypical savingRisk
Remove jQuery Migrate0 KB (dev only)None — it was never in production
Move to the slim build~6 KB gzipSilent loss of ajax and effects
Replace $.ajax with fetchEnables the slim buildError handling differs; check res.ok
Convert leaf modules0 KB until the last module goesPer-module regressions
Remove jQuery entirely~30 KB gzip, one less blocking requestEvery remaining call site breaks at once
Drop jQuery UI pluginsVaries, often largeReplace behaviour by behaviour

The point of the incremental order is that the last step is the only one where jQuery disappears — and by then almost nothing imports it, so the diff is small and the tests cover the converted code. Teams that start by trying to remove the script tag inevitably spend the effort on a rewrite instead of a migration.

What is worth keeping

  • A jQuery codebase that works and is not growing does not need migrating. The 30 KB is not the problem; the risk of the change is.
  • jQuery's event system normalises historical browser differences that no longer exist. That value is gone, and it is the main reason the library was ever needed.
  • What is worth copying from jQuery is not the API but the discipline: cheap selectors, delegated handlers, one teardown path, and returning objects that can be composed.
  • A third-party plugin you cannot replace is a legitimate reason to keep jQuery. Isolate it behind a module boundary so the rest of the codebase stays clean.
  • If you keep jQuery, keep the modern parts of it: on/off, prop for state, classList-style class helpers, and promises rather than callbacks. That is most of the benefit of a migration with none of the risk.
// A boundary that keeps a legacy plugin out of the rest of the app
// src/legacy/datepicker.js — the ONLY module that imports jQuery
import $ from 'jquery';
import 'jquery-ui/ui/widgets/datepicker';

export function mountDatepicker(input, onChange) {
  const $input = $(input);
  $input.datepicker({
    dateFormat: 'yy-mm-dd',
    onSelect(value) { onChange(value); }
  });
  return {
    getValue: () => $input.datepicker('getDate'),
    destroy: () => $input.datepicker('destroy')
  };
}

// Everywhere else: import { mountDatepicker } from './legacy/datepicker.js';
// The rest of the codebase never sees $, so removing jQuery later is a
// one-file change plus a replacement widget.

FAQ

Is jQuery still worth learning in 2026?
Yes for maintenance work: an enormous amount of production code uses it, and reading it fluently is a practical skill. For new projects the browser APIs it wrapped are now standard, so there is little reason to add the dependency.
How long does a jQuery removal take?
It depends on call-site count and whether plugins are involved, not on the size of the codebase. Convert leaf modules first, replace $.ajax early to unblock the slim build, and treat a third-party plugin you cannot replace as the real decision point.

Setting up jQuery and the module story AJAX helpers and why modern code moves on

Last refreshed 2026-09-18.