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
// 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
// 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
// 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
// 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
// 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
// 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:
// 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()
// 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
// 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');
#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 explicitresponse.okcheck 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 aNodeListcaptured earlier, the same way a non-delegated jQuery selection wouldn't include them either. - Reimplementing
.fadeIn()/.fadeOut()as a hand-rolledsetIntervalopacity loop instead of a CSStransition— it's both more code and less smooth than letting the browser's own compositor handle the animation.