Migrating Away from jQuery

A side-by-side mapping of common jQuery patterns to their vanilla JavaScript and fetch() equivalents.

Why migrate at all

The introduction page in this track was honest about jQuery's current status: its original reason for existing — papering over inconsistent, buggy cross-browser APIs — has largely disappeared, since modern browsers now converge on standard querySelector, addEventListener, and fetch(). That makes "remove jQuery from an existing codebase" a genuinely common, practical task — usually motivated by dropping an external dependency, shrinking a page's total JavaScript payload, or simplifying a build that no longer needs to load a whole library for what native APIs already cover. This page is a direct, side-by-side map from the jQuery patterns covered earlier in this track to their vanilla JavaScript equivalents.

Selecting elements

Javascript
// jQuery
const $items = $('.list-item');
const $first = $('.list-item').first();

// Vanilla
const items = document.querySelectorAll('.list-item'); // a NodeList, not an array
const first = document.querySelector('.list-item');

querySelectorAll returns a static NodeList — array-like, with .length and .forEach(), but without most Array methods (.map(), .filter()) unless explicitly converted with Array.from(items). jQuery's object smooths over this distinction; vanilla code needs the conversion spelled out when those methods are actually needed.

Reading and writing content

Javascript
// jQuery
$('#title').text();
$('#title').text('New Title');
$('#content').html('<strong>Bold</strong>');
$('#email').val();

// Vanilla
document.getElementById('title').textContent;
document.getElementById('title').textContent = 'New Title';
document.getElementById('content').innerHTML = '<strong>Bold</strong>';
document.getElementById('email').value;

Classes and attributes

Javascript
// jQuery
$('#card').addClass('highlighted');
$('#card').removeClass('highlighted');
$('#card').toggleClass('active');
$('#card').hasClass('active');
$('#link').attr('href', '/new-path');
$('#checkbox').prop('checked', true);

// Vanilla
document.getElementById('card').classList.add('highlighted');
document.getElementById('card').classList.remove('highlighted');
document.getElementById('card').classList.toggle('active');
document.getElementById('card').classList.contains('active');
document.getElementById('link').setAttribute('href', '/new-path');
document.getElementById('checkbox').checked = true;

classList maps almost one-to-one onto jQuery's class methods — this is one of the clearest examples of a native API having converged on exactly what jQuery offered, since classList didn't exist yet when jQuery was first written.

DOM traversal

Javascript
// jQuery
$('.card').parent();
$('.card').closest('.grid');
$('.card').children();
$('.card').find('.title');
$('.card').next();

// Vanilla
document.querySelector('.card').parentElement;
document.querySelector('.card').closest('.grid');
document.querySelector('.card').children;
document.querySelector('.card').querySelector('.title');
document.querySelector('.card').nextElementSibling;

closest() is a genuinely direct native equivalent — it was added to the DOM standard specifically because the pattern was so common in jQuery code.

Creating and inserting elements

Javascript
// jQuery
const $item = $('<li>').addClass('list-item').text('New item');
$('#list').append($item);
$('.list-item').remove();

// Vanilla
const item = document.createElement('li');
item.className = 'list-item';
item.textContent = 'New item';
document.getElementById('list').append(item);
document.querySelectorAll('.list-item').forEach(el => el.remove());

Events, including delegation

Javascript
// jQuery — direct binding
$('#save-btn').on('click', function () {
    console.log('Save clicked');
});

// Vanilla — direct binding
document.getElementById('save-btn').addEventListener('click', function () {
    console.log('Save clicked');
});

Delegation is the one case with no single native method — it has to be built from addEventListener plus a manual .closest()/.matches() check inside the handler:

Javascript
// jQuery — delegated, works for elements added later
$('#todo-list').on('click', '.delete-btn', function () {
    $(this).closest('li').remove();
});

// Vanilla — the equivalent delegation pattern
document.getElementById('todo-list').addEventListener('click', function (event) {
    const button = event.target.closest('.delete-btn');
    if (button) {
        button.closest('li').remove();
    }
});

Both versions correctly handle a <button class="delete-btn"> added to #todo-list after the page loaded — the handler is bound once to the stable ancestor either way, and event.target.closest('.delete-btn') in the vanilla version does the same "does the actual clicked element match" check jQuery's delegated .on() does internally.

AJAX: $.ajax() to fetch()

Javascript
// jQuery
$.ajax({ url: '/api/users', dataType: 'json' })
    .done(data => console.log(data))
    .fail(err => console.error(err));

// Vanilla
fetch('/api/users')
    .then(response => {
        if (!response.ok) throw new Error(`HTTP ${response.status}`); // fetch does NOT do this automatically
        return response.json();
    })
    .then(data => console.log(data))
    .catch(err => console.error(err));

The AJAX with jQuery page in this track already covered this gap in detail: $.ajax()'s .fail() fires automatically on an HTTP error status, while fetch() only rejects on an actual network failure — the explicit response.ok check above is not optional when migrating, it's the one behavior difference that silently changes what counts as "failed."

Show/hide and simple animation

Javascript
// jQuery
$('#panel').hide();
$('#panel').show();
$('#panel').fadeIn();
$('#panel').fadeOut();

// Vanilla — show/hide
document.getElementById('panel').style.display = 'none';
document.getElementById('panel').style.display = '';

// Vanilla — fade, using a CSS transition instead of a JS-driven animation
document.getElementById('panel').classList.add('is-visible');
Css
#panel {
    opacity: 0;
    transition: opacity 0.3s ease;
    pointer-events: none;
}

#panel.is-visible {
    opacity: 1;
    pointer-events: auto;
}

jQuery's .fadeIn()/.fadeOut() animate via repeated inline JavaScript style updates. The idiomatic native replacement isn't a JavaScript animation loop at all — it's toggling a class and letting a CSS transition (covered in depth on the CSS Animations & Transitions page in this track) do the actual animating, which also runs more smoothly since the browser can offload it to the compositor thread.

Side-by-side summary

Task jQuery Vanilla equivalent
Select one element $('#id') document.getElementById('id') / document.querySelector('#id')
Select many $('.class') document.querySelectorAll('.class')
Get/set text .text() .textContent
Get/set HTML .html() .innerHTML
Class toggle .toggleClass() .classList.toggle()
Nearest ancestor .closest() .closest() (identical)
Delegated events .on(event, selector, handler) addEventListener + event.target.closest(selector)
HTTP request $.ajax() / .done() / .fail() fetch() / .then() / explicit response.ok check / .catch()
Fade in/out .fadeIn() / .fadeOut() Toggle a class, animate with a CSS transition

A practical migration strategy

Rewriting an entire jQuery codebase in one pass is rarely worth the risk on a working production app. A safer, incremental path: stop writing new jQuery code, using the table above for anything freshly added; replace jQuery usage file by file as each one is touched for unrelated work anyway; and keep jQuery loaded until every usage is gone, rather than trying to remove the <script> tag before the last dependent file is migrated. The AJAX and event-delegation cases are usually the trickiest to get exactly right — worth double-checking against real user interactions (especially anything involving dynamically-added elements or error-handling paths) rather than assuming a mechanical find-and-replace of method names is sufficient.

Common mistakes

  • Assuming fetch() behaves like $.ajax() on an HTTP error response — forgetting the explicit response.ok check is the single most common bug introduced when migrating AJAX code.
  • Using document.querySelector() inside a loop the way jQuery's chainable style might have encouraged — re-running a selector query repeatedly is exactly the same performance mistake in vanilla JS as it is in jQuery; cache the result in a variable once instead.
  • Forgetting that document.querySelectorAll() returns a static snapshot at the time it's called, not a live list — elements added to the DOM afterward won't appear in a NodeList captured earlier, the same way a non-delegated jQuery selection wouldn't include them either.
  • Reimplementing .fadeIn()/.fadeOut() as a hand-rolled setInterval opacity loop instead of a CSS transition — it's both more code and less smooth than letting the browser's own compositor handle the animation.