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
| jQuery | Vanilla | Note |
|---|---|---|
$('#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.checked | Direct property access |
$el.val() | el.value | For a multi-select, map selectedOptions |
$el.text() / .html() | el.textContent / el.innerHTML | — |
$el.css('color') | getComputedStyle(el).color | Reads 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 transitions | Prefer 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>- 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.
- 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.
- 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.
- Replace
$.ajaxwithfetchnext. It is mechanical, and it removes the largest jQuery module from the bundle, which makes the slim build viable. - Replace animation last. It is the change with the most visual risk and the least functional benefit.
- 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';
}| Step | Typical saving | Risk |
|---|---|---|
| Remove jQuery Migrate | 0 KB (dev only) | None — it was never in production |
| Move to the slim build | ~6 KB gzip | Silent loss of ajax and effects |
Replace $.ajax with fetch | Enables the slim build | Error handling differs; check res.ok |
| Convert leaf modules | 0 KB until the last module goes | Per-module regressions |
| Remove jQuery entirely | ~30 KB gzip, one less blocking request | Every remaining call site breaks at once |
| Drop jQuery UI plugins | Varies, often large | Replace 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,propfor 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.Related
Setting up jQuery and the module story AJAX helpers and why modern code moves on
Last refreshed 2026-09-18.