querySelector replaces $(), fetch replaces $.ajax(), classList replaces addClass/removeClass, and the Web Animations API replaces $.animate(). With jQuery 4.0 (Jan 2026) dropping legacy browser support, the reasons to keep jQuery are fewer than ever. [src1, src2, src8]document.querySelector() / document.querySelectorAll()querySelectorAll returns a static NodeList, not a live jQuery collection — it does not have .map(), .filter(), or .reduce(), and it does not auto-update when the DOM changes. [src1, src3]fetch, closest(), and arrow functions. jQuery 4.0 also dropped IE10 and older. [src1, src4, src8]grep for all $( and jQuery( calls and count jQuery plugin dependencies BEFORE removing anything; a partial migration with jQuery still loaded is worse than no migration. [src7]querySelectorAll returns a static NodeList — you MUST iterate explicitly with forEach or spread into an array for .map()/.filter()/.reduce(); forgetting this is the #1 migration bug. [src1, src3]fetch() does NOT reject on HTTP errors (404, 500) — unlike $.ajax() which fires the error callback on 4xx/5xx, fetch resolves normally; always check response.ok before parsing the body. [src4]this context — jQuery callbacks bind this to the DOM element; arrow functions inherit this from the enclosing scope; use function declarations or e.currentTarget for equivalent behavior. [src7]window.jQuery — analytics, A/B testing tools (Optimizely, VWO), and CMS plugins (WordPress) often assume jQuery is globally available; audit ALL scripts, not just your own. [src7]| jQuery Pattern | Vanilla JS Equivalent | Notes |
|---|---|---|
$('#id') | document.querySelector('#id') | Returns single element or null [src1] |
$('.class') | document.querySelectorAll('.class') | Returns static NodeList, not live collection [src1] |
$(el).find('.child') | el.querySelectorAll('.child') | Scoped to element subtree [src2] |
$(el).closest('.parent') | el.closest('.parent') | Walks up the DOM tree, returns first match or null [src6] |
$(el).parent() | el.parentElement | Direct parent only [src2] |
$(el).siblings() | [...el.parentElement.children].filter(c => c !== el) | Spread into array, filter self [src3] |
$(el).addClass('x') | el.classList.add('x') | Supports multiple: .add('a','b') [src1] |
$(el).removeClass('x') | el.classList.remove('x') | Supports multiple classes [src1] |
$(el).toggleClass('x') | el.classList.toggle('x') | Returns true if added, false if removed [src2] |
$(el).hasClass('x') | el.classList.contains('x') | Returns boolean [src2] |
$(el).css('color','red') | el.style.color = 'red' | For multiple: Object.assign(el.style, {...}) [src3] |
$(el).attr('href') | el.getAttribute('href') | For setting: el.setAttribute('href', val) [src1] |
$(el).data('key') | el.dataset.key | Reads data-key attribute; auto-camelCases [src2] |
$(el).on('click', fn) | el.addEventListener('click', fn) | No shorthand — always explicit event name [src1] |
$(el).off('click', fn) | el.removeEventListener('click', fn) | Must pass same function reference [src3] |
$(el).trigger('click') | el.dispatchEvent(new Event('click')) | Use CustomEvent for custom data [src2] |
$(document).ready(fn) | document.addEventListener('DOMContentLoaded', fn) | Or place <script> at end of <body> [src1] |
$(el).text() | el.textContent | Getter and setter [src3] |
$(el).html() | el.innerHTML | Getter and setter — beware XSS [src3] |
$(el).val() | el.value | For form elements [src2] |
$(el).append(child) | el.appendChild(child) | Or el.append(child) (accepts strings too) [src1] |
$(el).prepend(child) | el.prepend(child) | Accepts nodes and strings [src2] |
$(el).before(sibling) | el.before(sibling) | Inserts before element [src2] |
$(el).after(sibling) | el.after(sibling) | Inserts after element [src2] |
$(el).remove() | el.remove() | Self-removing — no parent reference needed [src1] |
$(el).clone(true) | el.cloneNode(true) | true = deep clone including children [src2] |
$(el).show() / .hide() | el.style.display = '' / 'none' | Or toggle a CSS class [src3] |
$(el).fadeIn() | el.animate([{opacity:0},{opacity:1}], 300) | Web Animations API [src5] |
$(el).animate({...}) | el.animate(keyframes, options) | Web Animations API — returns Animation object [src5] |
$.ajax({url, ...}) | fetch(url, options) | Returns Promise — use async/await [src4] |
$.getJSON(url) | fetch(url).then(r => r.json()) | Always check response.ok [src4] |
$.each(arr, fn) | arr.forEach(fn) | Or for...of for break support [src2] |
$.extend(a, b) | Object.assign(a, b) | Or spread: {...a, ...b} (shallow only) [src3] |
$.inArray(val, arr) | arr.includes(val) | Returns boolean (not index) [src2] |
$.isArray(x) | Array.isArray(x) | Removed from jQuery 4.0 [src2, src8] |
$.map(arr, fn) | arr.map(fn) | Native array method [src2] |
$.trim(str) | str.trim() | Removed from jQuery 4.0 [src2, src8] |
$.proxy(fn, ctx) | fn.bind(ctx) | Or use arrow functions for lexical this [src3] |
$.Deferred() | new Promise((resolve, reject) => {...}) | Native Promises [src4] |
$.when(p1, p2) | Promise.all([p1, p2]) | Also Promise.race(), Promise.allSettled() [src4] |
START — Replacing jQuery in an existing project
├── Is jQuery loaded only for one or two features (e.g., AJAX + selectors)?
│ ├── YES → Replace those specific calls with native equivalents from Quick Reference
│ └── NO ↓
├── Is the codebase using jQuery plugins (datepicker, slick, select2)?
│ ├── YES → Find vanilla/framework alternatives first (Flatpickr, Swiper, Tom Select).
│ │ Migrate plugins BEFORE removing jQuery.
│ └── NO ↓
├── Does the codebase use jQuery UI (draggable, sortable, dialog)?
│ ├── YES → Replace with native HTML5 drag-and-drop, <dialog> element, or
│ │ lightweight libraries (SortableJS, interact.js). See Code Example 2.
│ └── NO ↓
├── Is the project a Single Page Application?
│ ├── YES → Consider migrating to a framework (React, Vue, Svelte) instead of
│ │ vanilla JS — they handle DOM updates more efficiently at scale.
│ └── NO ↓
├── Does the codebase use $.ajax() extensively?
│ ├── YES → Migrate AJAX calls first (fetch + async/await). See Step 3.
│ └── NO ↓
├── Is IE11 support required?
│ ├── YES → Add polyfills for fetch, closest(), NodeList.forEach, Promise.
│ │ Note: jQuery 4.0 also drops IE10.
│ └── NO ↓
└── DEFAULT → Follow the Step-by-Step Guide below for a systematic migration.
Identify every jQuery call in your codebase before changing anything. This determines your migration scope and reveals hidden plugin dependencies. [src7]
# Count total jQuery calls in your project
grep -r '\$(' src/ --include="*.js" | wc -l
# Find all unique jQuery methods used
grep -roP '\$\([^)]*\)\.\K[a-zA-Z]+' src/ --include="*.js" | sort | uniq -c | sort -rn
# Find jQuery plugin initializations
grep -rn '\.datepicker\|\.slick\|\.select2\|\.modal\|\.tooltip' src/ --include="*.js"
# Check which jQuery version is loaded
grep -r 'jquery' package.json bower.json *.html 2>/dev/null
Verify: Review the output — if you see more than 200 jQuery calls, consider a phased migration (one module at a time) rather than a big-bang rewrite.
This is the most common jQuery pattern — typically 40-60% of all jQuery calls are selectors. [src1, src3]
// BEFORE: jQuery selectors
const $header = $('#header');
const $items = $('.list-item');
const $active = $items.filter('.active');
// AFTER: Vanilla JS selectors
const header = document.querySelector('#header');
const items = document.querySelectorAll('.list-item');
const active = document.querySelectorAll('.list-item.active');
// Reusable helper for large codebases
const $$ = (sel, parent = document) => [...parent.querySelectorAll(sel)];
$$('.item').forEach(el => el.classList.add('visible'));
Verify: Open browser DevTools console, run document.querySelectorAll('.your-class').length — should match what jQuery returned.
fetch() is the modern replacement for $.ajax(), $.get(), $.post(), and $.getJSON(). It uses Promises natively and supports async/await. [src4]
// BEFORE: jQuery AJAX
$.ajax({
url: '/api/users',
method: 'GET',
dataType: 'json',
success: function(data) { console.log(data); },
error: function(xhr, status, err) { console.error(err); }
});
// AFTER: fetch with async/await
async function getUsers() {
try {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (err) {
console.error('Request failed:', err);
}
}
Verify: Check Network tab in DevTools — request headers and payload should be identical to the jQuery version.
Move from jQuery's .on() / .off() to native addEventListener / removeEventListener. Pay special attention to event delegation — use closest() instead of matches() for reliable behavior with nested elements. [src1, src6]
// BEFORE: jQuery event delegation
$('#list').on('click', '.item', function() {
$(this).addClass('selected');
});
// AFTER: Vanilla event delegation using closest()
document.querySelector('#list').addEventListener('click', (e) => {
const item = e.target.closest('.item');
if (!item) return;
item.classList.add('selected');
});
Verify: Click target elements. Test with dynamically added elements to confirm delegation works.
The Web Animations API provides performant, cancelable animations without jQuery. For simple transitions, CSS is even better. The API runs on the compositor thread for better performance. [src5]
// BEFORE: jQuery animate
$('#panel').animate({ opacity: 0, height: 0 }, 400, function() {
$(this).remove();
});
// AFTER: Web Animations API
const panel = document.querySelector('#panel');
const anim = panel.animate(
[{ opacity: 1, height: panel.offsetHeight + 'px' },
{ opacity: 0, height: '0px' }],
{ duration: 400, easing: 'ease-out', fill: 'forwards' }
);
anim.onfinish = () => panel.remove();
Verify: Compare animation timing and smoothness side-by-side. Use animation.finished (a Promise) to chain sequential animations instead of jQuery's built-in queue.
jQuery utilities like $.each, $.extend, $.inArray, $.trim all have native ES6+ equivalents. Note: jQuery 4.0 (Jan 2026) removed many of these utilities entirely. [src2, src3, src8]
// BEFORE // AFTER
$.extend({}, defaults, options) // { ...defaults, ...options }
$.inArray('apple', fruits) !== -1 // fruits.includes('apple')
$.trim(userInput) // userInput.trim()
$.map(items, fn) // items.map(fn)
$.isArray(data) // Array.isArray(data)
$.proxy(fn, ctx) // fn.bind(ctx)
$.Deferred() // new Promise((resolve, reject) => {...})
$.when(p1, p2) // Promise.all([p1, p2])
Verify: Run unit tests — utility behavior should be identical. Watch for $.extend deep merge vs Object.assign shallow merge.
Once all jQuery calls are replaced, remove the library and verify nothing breaks. [src7]
# Remove jQuery from package.json
npm uninstall jquery
# Search for any remaining $ or jQuery references
grep -rn '\$(\|jQuery(' src/ --include="*.js" --include="*.ts"
# Build and run tests
npm run build && npm test
Verify: Open the browser console — there should be no $ is not defined errors. Run full test suite.
Full script: javascript-complete-ajax-migration-with-error-hand.js (82 lines)
// Input: Legacy jQuery AJAX calls scattered across codebase
// Output: Modern fetch wrapper with retry, timeout, and error handling
class ApiClient {
constructor(baseUrl = '', defaultHeaders = {}) {
this.baseUrl = baseUrl;
this.defaultHeaders = {
'Content-Type': 'application/json',
...defaultHeaders
};
}
async request(endpoint, options = {}, retries = 2) {
const url = `${this.baseUrl}${endpoint}`;
const config = {
headers: { ...this.defaultHeaders, ...options.headers },
...options
};
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
options.timeout || 30000
);
config.signal = controller.signal;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const response = await fetch(url, config);
clearTimeout(timeout);
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`HTTP ${response.status}: ${errorBody}`);
}
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
return await response.json();
}
return await response.text();
} catch (err) {
clearTimeout(timeout);
if (attempt === retries) throw err;
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)));
}
}
}
get(endpoint, opts) { return this.request(endpoint, { method: 'GET', ...opts }); }
post(endpoint, data, opts) {
return this.request(endpoint, { method: 'POST', body: JSON.stringify(data), ...opts });
}
put(endpoint, data, opts) {
return this.request(endpoint, { method: 'PUT', body: JSON.stringify(data), ...opts });
}
delete(endpoint, opts) { return this.request(endpoint, { method: 'DELETE', ...opts }); }
}
// Usage
const api = new ApiClient('/api/v1');
const users = await api.get('/users');
const created = await api.post('/users', { name: 'Alice' });
Full script: javascript-jquery-plugin-replacement-delegated-eve.js (66 lines)
// Input: jQuery-style event delegation ($(parent).on('click', '.child', fn))
// Output: Vanilla JS event delegation utility with namespace support
class EventDelegate {
constructor(root = document) {
this.root = typeof root === 'string'
? document.querySelector(root) : root;
this._handlers = new Map();
}
on(event, selector, handler, namespace = 'default') {
const wrapped = (e) => {
const target = e.target.closest(selector);
if (target && this.root.contains(target)) {
handler.call(target, e, target);
}
};
this.root.addEventListener(event, wrapped);
if (!this._handlers.has(namespace)) {
this._handlers.set(namespace, []);
}
this._handlers.get(namespace).push({ event, wrapped });
return this;
}
off(namespace = 'default') {
const handlers = this._handlers.get(namespace) || [];
handlers.forEach(({ event, wrapped }) => {
this.root.removeEventListener(event, wrapped);
});
this._handlers.delete(namespace);
return this;
}
one(event, selector, handler, namespace = 'default') {
const onceHandler = (e, target) => {
handler.call(target, e, target);
this.off(namespace);
};
return this.on(event, selector, onceHandler, namespace);
}
destroy() {
for (const ns of this._handlers.keys()) this.off(ns);
}
}
// Usage
const delegate = new EventDelegate('#app');
delegate
.on('click', '.btn-delete', function(e) {
this.closest('.card').remove();
})
.on('input', '.search-field', function(e) {
filterList(this.value);
}, 'search');
delegate.off('search');
Full script: javascript-automated-jquery-to-vanilla-codemod-wit.js (37 lines)
// Input: jscodeshift transform file — run against jQuery codebase
// Output: Automatically converts common jQuery patterns to vanilla JS
// Usage: npx jscodeshift -t jquery-to-vanilla.js src/ --extensions=js
module.exports = function(fileInfo, api) {
const j = api.jscodeshift;
let root = j(fileInfo.source);
let modified = false;
// Transform: $('#id') → document.querySelector('#id')
root.find(j.CallExpression, {
callee: { name: '$' },
arguments: [{ type: 'Literal' }]
}).forEach(path => {
const selector = path.node.arguments[0].value;
if (typeof selector === 'string' && selector.match(/^[#.a-z]/i)) {
const replacement = j.callExpression(
j.memberExpression(
j.identifier('document'),
j.identifier(
selector.startsWith('#') ? 'querySelector' : 'querySelectorAll'
)
),
[j.literal(selector)]
);
j(path).replaceWith(replacement);
modified = true;
}
});
return modified ? root.toSource() : fileInfo.source;
};
// BAD — innerHTML is slow, destroys event listeners, and creates XSS vulnerabilities
const list = document.querySelector('#list');
list.innerHTML += `<li>${userInput}</li>`; // Re-parses entire list, XSS risk
// GOOD — createElement preserves existing listeners and prevents XSS
const list = document.querySelector('#list');
const li = document.createElement('li');
li.textContent = userInput; // textContent auto-escapes HTML
list.appendChild(li);
// BAD — fails when clicking on child elements inside the target
document.querySelector('#list').addEventListener('click', (e) => {
if (e.target.matches('.item')) { // misses clicks on <span> inside .item
e.target.classList.add('selected');
}
});
// GOOD — closest() walks up the tree and catches nested clicks [src6]
document.querySelector('#list').addEventListener('click', (e) => {
const item = e.target.closest('.item');
if (!item) return;
item.classList.add('selected');
});
// BAD — fetch does NOT throw on HTTP errors (404, 500)
const data = await fetch('/api/users').then(r => r.json());
// Silently succeeds even on 404
// GOOD — explicit error handling for HTTP status codes [src4]
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
// BAD — NodeList doesn't have .map(), .filter(), .reduce()
const items = document.querySelectorAll('.item');
const names = items.map(el => el.textContent); // TypeError
// GOOD — spread into array or use Array.from() [src2, src3]
const items = [...document.querySelectorAll('.item')];
const names = items.map(el => el.textContent);
// Also good — Array.from with mapping function
const names2 = Array.from(
document.querySelectorAll('.item'),
el => el.textContent
);
// BAD — Object.assign does shallow merge only
const defaults = { ui: { theme: 'light', fontSize: 14 } };
const userPrefs = { ui: { theme: 'dark' } };
const config = Object.assign({}, defaults, userPrefs);
// config.ui = { theme: 'dark' } — fontSize is LOST!
// GOOD — custom deep merge function [src3]
function deepMerge(target, ...sources) {
for (const source of sources) {
for (const key of Object.keys(source)) {
if (source[key] && typeof source[key] === 'object'
&& !Array.isArray(source[key])) {
target[key] = deepMerge(target[key] || {}, source[key]);
} else {
target[key] = source[key];
}
}
}
return target;
}
const config = deepMerge({}, defaults, userPrefs);
// config.ui = { theme: 'dark', fontSize: 14 } — both preserved
querySelectorAll returns a static NodeList: Unlike jQuery collections, it does not auto-update when the DOM changes. Fix: re-run querySelectorAll() after DOM mutations, or use MutationObserver. [src1, src3]removeEventListener requires the same function reference: Anonymous functions cannot be removed. Fix: store handler functions in named variables. [src3]fetch does not reject on HTTP errors: A 404 or 500 response resolves the promise normally. Fix: always check response.ok before parsing. [src4]fetch does not send cookies by default (cross-origin): Unlike $.ajax with xhrFields. Fix: add credentials: 'include' to fetch options. [src4]this behaves differently in arrow functions: Arrow functions inherit this from enclosing scope, unlike jQuery callbacks where this is the DOM element. Fix: use function declaration or e.currentTarget. [src7]element.animate() plays simultaneously. Fix: use animation.finished Promise to chain. [src5]classList.toggle() second argument ignored in IE11: el.classList.toggle('active', condition) adds/removes based on boolean, but IE11 ignores the second argument. Fix: use classList.add/remove with a conditional. [src2]$('.items').hide() hides all matched elements. Vanilla JS requires explicit looping: document.querySelectorAll('.items').forEach(el => el.style.display = 'none'). Forgetting this is the #1 source of bugs during migration. [src3]# Count remaining jQuery references in your codebase
grep -rn '\$(\|jQuery(' src/ --include="*.js" --include="*.ts" | wc -l
# Find jQuery CDN or local file references in HTML
grep -rn 'jquery\|jQuery' *.html templates/ --include="*.html"
# Check if jQuery is still in node_modules
ls node_modules/jquery/dist/jquery.min.js 2>/dev/null && echo "Still installed" || echo "Removed"
# Check bundle size impact (if using webpack/rollup)
npx webpack --profile --json > stats.json
npx webpack-bundle-analyzer stats.json
# Verify no runtime jQuery dependency (browser console)
# console.log(typeof jQuery, typeof $); // should both be "undefined"
# Find files still importing jQuery via ES modules
grep -rn "import.*jquery\|require.*jquery" src/ --include="*.js" --include="*.ts"
# Run eslint to catch remaining $ usage
npx eslint src/ --rule '{"no-restricted-globals": ["error", {"name": "$", "message": "Use querySelector"}]}'
| Technology | Version | Key APIs Available | Notes |
|---|---|---|---|
| ES6 / ES2015 | 2015+ | let/const, arrow functions, template literals, Promise, class | Baseline for migration [src1] |
| ES2017 | 2017+ | async/await | Eliminates callback-based AJAX [src4] |
fetch API | Chrome 42+, FF 39+, Safari 10.1+ | fetch(), Request, Response, AbortController | IE11: use whatwg-fetch polyfill. New: fetchLater() experimental (2025+) [src4] |
closest() | Chrome 41+, FF 35+, Safari 9+ | Element.closest() | Baseline since April 2017. IE11: use MDN polyfill [src6] |
classList | Chrome 8+, FF 3.6+, Safari 5.1+ | add(), remove(), toggle(), contains(), replace() | IE11 lacks multi-argument support [src1] |
| Web Animations API | Chrome 36+, FF 48+, Safari 13.1+ | Element.animate(), Animation, KeyframeEffect | animation.finished returns Promise for chaining [src5] |
structuredClone | Chrome 98+, FF 94+, Safari 15.4+ | Deep copy objects | Cannot clone DOM nodes, functions, or Error objects [src2] |
| jQuery 3.7.x | Current LTS | Full legacy API | Still maintained; EOL timeline TBD [src8] |
| jQuery 4.0.0 | Released 2026-01-17 | ES modules, Trusted Types, slim build (~19.5KB gz) | Drops IE10, Edge Legacy; removes $.isArray, $.trim, $.type, $.now, $.parseJSON, etc. [src8] |
| Use Vanilla JS When | Keep jQuery When | Consider Instead |
|---|---|---|
| Starting a new project with modern browser targets | Maintaining legacy app with extensive jQuery plugin ecosystem | React/Vue/Svelte for complex SPAs |
| Bundle size is critical (jQuery 4.0 slim is ~19.5KB gzipped) | Team velocity matters more than bundle size on internal tools | Preact (3KB) for React API without the size |
| You need only a few jQuery features (selectors, AJAX, events) | You need jQuery UI components and no time to replace them | Alpine.js or htmx for progressive enhancement |
| Building a zero-dependency library | IE11 is a hard requirement and polyfill management is undesirable | Cash (~6KB) or Zepto (~10KB) as lightweight alternatives |
| Performance is critical (direct DOM access is faster) | Codebase has 1000+ jQuery calls and no test coverage | TypeScript migration first, then remove jQuery incrementally |
$.extend(true, ...) deep merge has no single native equivalent — Object.assign is shallow only. Use structuredClone for deep copies or implement a custom deep merge. structuredClone cannot clone DOM elements, functions, or Error objects. [src2]$('.items').hide() hides all matched elements. Vanilla JS requires explicit looping. Forgetting this is the #1 source of bugs during migration. [src3]$ variable may conflict — other libraries (Prototype.js, MooTools, Cash) also use $. After removing jQuery, ensure no other library expects window.$. [src2]$.isArray, $.parseJSON, $.trim, $.type, $.now, $.isNumeric, $.isFunction, $.isWindow, $.camelCase, and $.nodeName are all removed. Use their native equivalents. [src8]