How to Replace jQuery with Modern Vanilla JavaScript (ES6+)

Type: Software Reference Confidence: 0.95 Sources: 8 Verified: 2026-02-22 Freshness: quarterly

TL;DR

Constraints

Quick Reference

jQuery PatternVanilla JS EquivalentNotes
$('#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.parentElementDirect 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.keyReads 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.textContentGetter and setter [src3]
$(el).html()el.innerHTMLGetter and setter — beware XSS [src3]
$(el).val()el.valueFor 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]

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

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

TechnologyVersionKey APIs AvailableNotes
ES6 / ES20152015+let/const, arrow functions, template literals, Promise, classBaseline for migration [src1]
ES20172017+async/awaitEliminates callback-based AJAX [src4]
fetch APIChrome 42+, FF 39+, Safari 10.1+fetch(), Request, Response, AbortControllerIE11: 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]
classListChrome 8+, FF 3.6+, Safari 5.1+add(), remove(), toggle(), contains(), replace()IE11 lacks multi-argument support [src1]
Web Animations APIChrome 36+, FF 48+, Safari 13.1+Element.animate(), Animation, KeyframeEffectanimation.finished returns Promise for chaining [src5]
structuredCloneChrome 98+, FF 94+, Safari 15.4+Deep copy objectsCannot clone DOM nodes, functions, or Error objects [src2]
jQuery 3.7.xCurrent LTSFull legacy APIStill maintained; EOL timeline TBD [src8]
jQuery 4.0.0Released 2026-01-17ES modules, Trusted Types, slim build (~19.5KB gz)Drops IE10, Edge Legacy; removes $.isArray, $.trim, $.type, $.now, $.parseJSON, etc. [src8]

When to Use / When Not to Use

Use Vanilla JS WhenKeep jQuery WhenConsider Instead
Starting a new project with modern browser targetsMaintaining legacy app with extensive jQuery plugin ecosystemReact/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 toolsPreact (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 themAlpine.js or htmx for progressive enhancement
Building a zero-dependency libraryIE11 is a hard requirement and polyfill management is undesirableCash (~6KB) or Zepto (~10KB) as lightweight alternatives
Performance is critical (direct DOM access is faster)Codebase has 1000+ jQuery calls and no test coverageTypeScript migration first, then remove jQuery incrementally

Important Caveats

Related Units