How to Replace jQuery with Modern Vanilla JavaScript (ES6+)
How do I replace jQuery with modern vanilla JavaScript (ES6+)?
TL;DR
- Bottom line: Every jQuery method has a native browser equivalent in ES6+ —
querySelectorreplaces$(),fetchreplaces$.ajax(),classListreplacesaddClass/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] - Key tool/command:
document.querySelector()/document.querySelectorAll() - Watch out for:
querySelectorAllreturns a staticNodeList, 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] - Works with: All modern browsers (Chrome 60+, Firefox 55+, Safari 12+, Edge 79+). IE11 needs polyfills for
fetch,closest(), and arrow functions. jQuery 4.0 also dropped IE10 and older. [src1, src4, src8]
Constraints
- Audit before migrating — run
grepfor all$(andjQuery(calls and count jQuery plugin dependencies BEFORE removing anything; a partial migration with jQuery still loaded is worse than no migration. [src7] - Migrate jQuery plugins FIRST — plugins like Select2, Slick, DataTables hard-depend on jQuery; replace them with vanilla alternatives (Tom Select, Swiper, AG Grid) or their framework-native equivalents before removing jQuery. [src1, src2]
querySelectorAllreturns a staticNodeList— you MUST iterate explicitly withforEachor 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,fetchresolves normally; always checkresponse.okbefore parsing the body. [src4]- Arrow functions change
thiscontext — jQuery callbacks bindthisto the DOM element; arrow functions inheritthisfrom the enclosing scope; usefunctiondeclarations ore.currentTargetfor equivalent behavior. [src7] - Third-party scripts may depend on
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]
Quick Reference
| 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. jQuery 4.0 slim removed Deferred entirely [src4, src8] |
$.when(p1, p2) | Promise.all([p1, p2]) | Also Promise.race(), Promise.allSettled(), Promise.any() [src4] |
$.Deferred() with external resolve | Promise.withResolvers() | Returns {promise, resolve, reject} — ES2024, Chrome 119+, FF 121+, Safari 17.4+ [src4] |
$.ajax({timeout}) | AbortSignal.timeout(ms) | fetch(url, {signal: AbortSignal.timeout(5000)}) — ES2024, Chrome 124+, FF 102+, Safari 17.4+ [src4] |
$.groupBy() (never existed) | Object.groupBy(arr, fn) | Group array items by key — ES2024, Chrome 117+, FF 119+, Safari 17.4+ [src4] |
$(items).filter(set1).filter(set2) | new Set(a).intersection(new Set(b)) | Native Set methods: .union(), .intersection(), .difference(), .symmetricDifference(), .isSubsetOf() — ES2025, Chrome 122+, FF 127+, Safari 17+ [src4] |
Decision Tree
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.
Step-by-Step Guide
1. Audit jQuery usage and set up tooling
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.
2. Replace DOM selection and traversal
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.
3. Replace AJAX calls with fetch
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.
4. Replace event handling
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.
5. Replace animations with Web Animations API or CSS transitions
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.
6. Replace utility functions
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.
7. Remove jQuery dependency
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.
Code Examples
JavaScript: Complete AJAX Migration with Error Handling and Retry
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' });
JavaScript: jQuery Plugin Replacement — Delegated Event System
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');
JavaScript: Automated jQuery-to-Vanilla Codemod with jscodeshift
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;
};
Anti-Patterns
Wrong: Using innerHTML for everything instead of proper DOM methods
// 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
Correct: Use DOM methods or sanitize input
// 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);
Wrong: Using matches() instead of closest() for event delegation
// 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');
}
});
Correct: Use closest() for reliable event delegation
// 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');
});
Wrong: Not checking response.ok with fetch
// 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
Correct: Always check response.ok before parsing
// 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();
Wrong: Forgetting that querySelectorAll returns a NodeList, not an Array
// BAD — NodeList doesn't have .map(), .filter(), .reduce()
const items = document.querySelectorAll('.item');
const names = items.map(el => el.textContent); // TypeError
Correct: Convert NodeList to Array before using array methods
// 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
);
Wrong: Using Object.assign for deep merge (replacing $.extend(true, ...))
// 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!
Correct: Use structuredClone or a deep merge utility
// 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
Common Pitfalls
querySelectorAllreturns a static NodeList: Unlike jQuery collections, it does not auto-update when the DOM changes. Fix: re-runquerySelectorAll()after DOM mutations, or useMutationObserver. [src1, src3]removeEventListenerrequires the same function reference: Anonymous functions cannot be removed. Fix: store handler functions in named variables. [src3]fetchdoes not reject on HTTP errors: A 404 or 500 response resolves the promise normally. Fix: always checkresponse.okbefore parsing. [src4]fetchdoes not send cookies by default (cross-origin): Unlike$.ajaxwithxhrFields. Fix: addcredentials: 'include'to fetch options. [src4]thisbehaves differently in arrow functions: Arrow functions inheritthisfrom enclosing scope, unlike jQuery callbacks wherethisis the DOM element. Fix: usefunctiondeclaration ore.currentTarget. [src7]- No built-in animation queue: jQuery auto-queues animations;
element.animate()plays simultaneously. Fix: useanimation.finishedPromise 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: useclassList.add/removewith a conditional. [src2]- jQuery's implicit iteration is gone:
$('.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]
Diagnostic Commands
# 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"}]}'
Version History & Compatibility
| 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] |
| ES2024 | 2024+ | Object.groupBy, Map.groupBy, Promise.withResolvers, AbortSignal.timeout, Array.fromAsync | Eliminates more jQuery utility helpers. Baseline in Chrome 124+, FF 126+, Safari 17.4+ [src4] |
| ES2025 | 2025+ | Set methods: .union(), .intersection(), .difference(), .symmetricDifference(), .isSubsetOf() | Native set algebra without jQuery .filter().filter() chains. Chrome 122+, FF 127+, Safari 17+ [src4] |
| 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), Rollup packaging | Drops IE10, Edge Legacy, older iOS/Android Browser. IE11 retained but planned for removal in 5.0. Removes $.isArray, $.trim, $.type, $.now, $.parseJSON, deferreds/callbacks in slim. Event handling now follows web standards more strictly [src8] |
When to Use / When Not to Use
| 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 |
Important Caveats
- jQuery normalizes cross-browser behavior — vanilla JS may behave differently across browsers for edge cases like event properties, AJAX error handling, and animation timing. Always test in your target browser matrix after migration. [src1]
$.extend(true, ...)deep merge has no single native equivalent —Object.assignis shallow only. UsestructuredClonefor deep copies or implement a custom deep merge.structuredClonecannot clone DOM elements, functions, orErrorobjects. [src2]- jQuery's implicit iteration is gone —
$('.items').hide()hides all matched elements. Vanilla JS requires explicit looping. Forgetting this is the #1 source of bugs during migration. [src3] - Removing jQuery may break third-party scripts — analytics snippets, A/B testing tools (Optimizely, VWO), and WordPress plugins often assume jQuery is globally available. Audit all scripts. [src7]
- The
$variable may conflict — other libraries (Prototype.js, MooTools, Cash) also use$. After removing jQuery, ensure no other library expectswindow.$. [src2] - jQuery 4.0 removes deprecated utilities — if upgrading to 4.0 instead of migrating to vanilla JS, note that
$.isArray,$.parseJSON,$.trim,$.type,$.now,$.isNumeric,$.isFunction,$.isWindow,$.camelCase, and$.nodeNameare all removed. Use their native equivalents. [src8]