EthicsComputerCourses
Join training +91 90532 10052

Migrating to Plain JavaScript

jQuery · Lesson 33 of 36 Advanced 6 min read Updated

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

  1. Upgrade first. Get the site to jQuery 4 (see jQuery 3 to 4: What Changed) so you are working from current behaviour.
  2. 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.
  3. Migrate one feature at a time. jQuery and plain code happily run side by side, so a page can be half-migrated for weeks.
  4. Keep behaviour identical. Test each feature before and after: clicks, keyboard use, error cases, slow networks.
  5. Remove jQuery last, page by page, once nothing on that page uses it.

The cheat sheet

jQueryPlain 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 / toggleClassel.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, $.getJSONfetch() 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:

JavaScript
// 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:

HTMLTry it yourself
<!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:

HTMLTry it yourself
<!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").classList is undefined; loop with forEach. And querySelector returns null when nothing matches, so null.textContent throws where jQuery silently did nothing.
  • return false. It prevented the default and stopped bubbling in jQuery; in addEventListener it does nothing. Call event.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.price gives a string.
  • HTTP errors. $.ajax failed on 404 and 500; fetch resolves, so check response.ok.
  • Non-bubbling events. jQuery delegated focus, blur, mouseenter and mouseleave for you; use focusin, focusout, mouseover and mouseout when delegating.
  • this in arrow functions. Converting function () { $(this)... } to an arrow function loses this; use event.currentTarget.
  • .show() restoring display. jQuery remembered the original display. With hidden, 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 delegated on() keep plain code short.
  • Watch for lost implicit iteration, null results, return false, string vs HTML in append, string dataset values and fetch not 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
HTML
<!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")?

2. A migrated handler uses return false to stop a link. Why does the link still navigate?

3. Why can the $(function () { ... }) wrapper be dropped in the migrated example?

4. Which is the safest way to remove jQuery from a large site?

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