Migrating to Plain JavaScript
Removing jQuery from a working site is rarely urgent, but it is often worth doing piece by piece: pages load faster, there is one less library to upgrade, and new developers on the team only need to know the browser's own API. The safest approach is not a big rewrite but a steady migration, one feature at a time, testing as you go.
This lesson collects everything from the course into a practical guide: a translation cheat sheet, two tiny helpers that make plain code nearly as short, a complete before-and-after example, and the subtle differences that cause most migration bugs.
Plan the migration
- Upgrade first. Get the site to jQuery 4 (see jQuery 3 to 4: What Changed) so you are working from current behaviour.
- List what depends on jQuery. Search your code for
$(,jQuery,$.and.fn., and list every plugin. A plugin with no plain alternative keeps jQuery on that page. - Migrate one feature at a time. jQuery and plain code happily run side by side, so a page can be half-migrated for weeks.
- Keep behaviour identical. Test each feature before and after: clicks, keyboard use, error cases, slow networks.
- Remove jQuery last, page by page, once nothing on that page uses it.
The cheat sheet
| jQuery | Plain JavaScript |
|---|---|
$(fn) | defer or type="module" on the script; or DOMContentLoaded |
$("#id"), $(".a") | document.querySelector("#id"), document.querySelectorAll(".a") |
$el.find(".x") | el.querySelectorAll(".x") |
$el.closest(".card") | el.closest(".card") |
$el.parent(), .children() | el.parentElement, el.children |
$el.next(), .prev() | el.nextElementSibling, el.previousElementSibling |
$list.each((i, el) => ...) | list.forEach((el, i) => ...) |
$el.text(), .text(v) | el.textContent, el.textContent = v |
$el.val() | el.value |
$el.attr("a"), .attr("a", v) | el.getAttribute("a"), el.setAttribute("a", v) |
$el.prop("checked") | el.checked |
$el.data("price") | Number(el.dataset.price) (dataset is always a string) |
$el.addClass / removeClass / toggleClass | el.classList.add / remove / toggle |
$el.css("color", "red") | el.style.color = "red" |
$el.append(x), .before(x), .remove() | el.append(x), el.before(x), el.remove() |
$el.empty() | el.replaceChildren() |
$el.hide(), .show() | el.hidden = true, el.hidden = false |
$el.fadeIn() | CSS transition on a class, or el.animate(...) |
$el.on("click", fn) | el.addEventListener("click", fn) |
$el.on("click", ".item", fn) | one listener plus event.target.closest(".item") |
$el.trigger("custom") | el.dispatchEvent(new CustomEvent("custom", { bubbles: true })) |
$.ajax, $.getJSON | fetch() plus a response.ok check |
$form.serialize() | new URLSearchParams(new FormData(form)) |
$.extend({}, a, b) | { ...a, ...b } |
$.each(obj, fn) | for (const [key, value] of Object.entries(obj)) |
Two tiny helpers
Most plain code is longer only because of document.querySelectorAll and delegation boilerplate. Two small helpers remove most of that, without pulling in a library:
// Select many elements as a real array
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
// Delegated events, like $(parent).on(type, selector, handler)
function on(parent, type, selector, handler, options) {
parent.addEventListener(type, (event) => {
const match = event.target.closest(selector);
if (match && parent.contains(match)) handler.call(match, event, match);
}, options);
}
// Usage
$$(".price").forEach((el) => el.classList.add("sale"));
on(document.querySelector("#cart"), "click", ".remove", (event, button) => button.closest("li").remove());Keep helpers this small. If you find yourself rebuilding half of jQuery, it may be wiser to keep jQuery on that page for now.
A worked example: before
Here is a typical small jQuery widget from an older site: a course enquiry box with tabs, a form sent by AJAX, and a message that fades in. Run it and try both tabs and the form:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Before: jQuery</title>
<style>
.tabs button.active { background: #0769ad; color: #fff; }
.panel { border: 1px solid #ccc; padding: 0.6rem; max-inline-size: 26rem; }
#thanks { color: #2e7d32; }
</style>
</head>
<body>
<div class="tabs">
<button class="active" data-panel="fees">Fees</button>
<button data-panel="enquire">Enquire</button>
</div>
<div class="panel" id="fees">Hostel fee in Pune: ₹6,500 per month, meals included.</div>
<div class="panel" id="enquire" style="display: none">
<form id="enquiry">
<label>Name <input name="name" required value="Ananya"></label>
<label>Phone <input name="phone" required value="9876543210"></label>
<button>Send</button>
</form>
<p id="thanks" style="display: none"></p>
</div>
<script src="https://cdn.jsdelivr.net/npm/jquery@4.0.0/dist/jquery.min.js"></script>
<script>
$(function () {
$(".tabs").on("click", "button", function () {
$(this).addClass("active").siblings().removeClass("active");
$(".panel").hide();
$("#" + $(this).data("panel")).show();
});
$("#enquiry").on("submit", function (event) {
event.preventDefault();
const $form = $(this);
$form.find("button").prop("disabled", true);
$.post("https://jsonplaceholder.typicode.com/posts", $form.serialize())
.done(function (saved) {
$("#thanks").text("Thanks " + saved.name + ", reference #" + saved.id).fadeIn();
$form[0].reset();
})
.fail(function () {
$("#thanks").text("Sorry, please try again.").fadeIn();
})
.always(function () {
$form.find("button").prop("disabled", false);
});
});
});
</script>
</body>
</html>The same widget: after
The migrated version keeps the HTML almost unchanged. The style="display: none" becomes the hidden attribute, the fade becomes a CSS transition, and every jQuery call is translated with the cheat sheet:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>After: plain JavaScript</title>
<style>
.tabs button.active { background: #0769ad; color: #fff; }
.panel { border: 1px solid #ccc; padding: 0.6rem; max-inline-size: 26rem; }
#thanks { color: #2e7d32; opacity: 0; transition: opacity 0.4s; }
#thanks.visible { opacity: 1; }
</style>
</head>
<body>
<div class="tabs">
<button class="active" data-panel="fees">Fees</button>
<button data-panel="enquire">Enquire</button>
</div>
<div class="panel" id="fees">Hostel fee in Pune: ₹6,500 per month, meals included.</div>
<div class="panel" id="enquire" hidden>
<form id="enquiry">
<label>Name <input name="name" required value="Ananya"></label>
<label>Phone <input name="phone" required value="9876543210"></label>
<button>Send</button>
</form>
<p id="thanks"></p>
</div>
<script type="module">
const tabs = document.querySelector(".tabs");
const panels = document.querySelectorAll(".panel");
const thanks = document.querySelector("#thanks");
tabs.addEventListener("click", (event) => {
const button = event.target.closest("button");
if (!button) return;
for (const b of tabs.children) b.classList.toggle("active", b === button);
panels.forEach((p) => { p.hidden = p.id !== button.dataset.panel; });
});
document.querySelector("#enquiry").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const button = form.querySelector("button");
button.disabled = true;
try {
const response = await fetch("https://jsonplaceholder.typicode.com/posts", {
method: "POST",
body: new URLSearchParams(new FormData(form)),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const saved = await response.json();
thanks.textContent = `Thanks ${saved.name}, reference #${saved.id}`;
form.reset();
} catch {
thanks.textContent = "Sorry, please try again.";
} finally {
thanks.classList.add("visible");
button.disabled = false;
}
});
</script>
</body>
</html>It behaves the same and needs no library. A type="module" script is deferred automatically, so the $(function () { ... }) wrapper simply disappears.
Traps that break migrations
- No implicit iteration.
document.querySelectorAll(".x").classListisundefined; loop withforEach. AndquerySelectorreturnsnullwhen nothing matches, sonull.textContentthrows where jQuery silently did nothing. return false. It prevented the default and stopped bubbling in jQuery; inaddEventListenerit does nothing. Callevent.preventDefault().- Strings in
append(). jQuery parses them as HTML; the DOM inserts them as text. Usually that is safer, but markup you relied on will appear as visible tags. - Data types.
.data("price")gave a number;dataset.pricegives a string. - HTTP errors.
$.ajaxfailed on 404 and 500;fetchresolves, so checkresponse.ok. - Non-bubbling events. jQuery delegated
focus,blur,mouseenterandmouseleavefor you; usefocusin,focusout,mouseoverandmouseoutwhen delegating. thisin arrow functions. Convertingfunction () { $(this)... }to an arrow function losesthis; useevent.currentTarget..show()restoring display. jQuery remembered the originaldisplay. Withhidden, make sure no CSS rule overrides it.
Common mistakes
- Rewriting everything at once. Big-bang rewrites hide bugs. Migrate and release one feature at a time.
- Removing jQuery while a plugin still needs it. Check plugin dependencies first.
- Recreating jQuery with dozens of helpers. Two or three tiny helpers are fine; a homemade library is not.
- Skipping keyboard and error testing. Migrations often break Enter-to-submit, focus handling and failure messages.
Key points
- Migrate gradually: upgrade to jQuery 4, list dependencies, convert one feature at a time, remove jQuery last.
- Most jQuery calls map one-to-one to DOM methods:
querySelector,closest,classList,textContent,append,addEventListener,fetch. - Tiny helpers such as
$$()and a delegatedon()keep plain code short. - Watch for lost implicit iteration,
nullresults,return false, string vs HTML inappend, stringdatasetvalues andfetchnot failing on 404. - Module scripts are deferred, so ready wrappers are no longer needed.
Exercise
Migrate this jQuery code to plain JavaScript and test both versions on the same HTML (a <ul id="menu"> of links and a <p id="current">): $(function () { $("#menu").on("click", "a", function (e) { e.preventDefault(); $("#menu a").removeClass("current"); $(this).addClass("current"); $("#current").text("You chose " + $(this).text()).fadeIn(200); }); });. Use one delegated listener, closest(), classList and the hidden attribute (no animation needed).
Show solution
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Menu</title>
<style>.current { font-weight: bold; }</style>
</head>
<body>
<ul id="menu">
<li><a href="#maths">Maths</a></li>
<li><a href="#science">Science</a></li>
<li><a href="#hindi">Hindi</a></li>
</ul>
<p id="current" hidden></p>
<script type="module">
const menu = document.querySelector("#menu");
const current = document.querySelector("#current");
menu.addEventListener("click", (event) => {
const link = event.target.closest("a");
if (!link || !menu.contains(link)) return;
event.preventDefault();
menu.querySelectorAll("a").forEach((a) => a.classList.toggle("current", a === link));
current.textContent = `You chose ${link.textContent}`;
current.hidden = false;
});
</script>
</body>
</html>The delegated listener with closest("a") replaces .on("click", "a", ...), classList.toggle with a condition replaces the remove-then-add pair, and hidden = false replaces .fadeIn().
Quick Quiz
1. What is the plain JavaScript equivalent of $(".item").addClass("on")?
A NodeList has no classList; you must loop over the elements.
2. A migrated handler uses return false to stop a link. Why does the link still navigate?
Only jQuery treated return false as preventDefault plus stopPropagation.
3. Why can the $(function () { ... }) wrapper be dropped in the migrated example?
Module scripts behave like defer scripts.
4. Which is the safest way to remove jQuery from a large site?
Gradual migration keeps every release working and makes bugs easy to trace.
Learn jQuery with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
