EthicsComputerCourses
Join training +91 90532 10052

Project: Refactor a Legacy jQuery Page

jQuery · Lesson 36 of 36 Advanced 9 min read Updated

In this project you take over a page the way it happens at work: a fee dashboard for a coaching institute in Jaipur, written in 2013, still used every day by the accounts team. It works, mostly. Your job is to make it safe and modern without breaking what people rely on, and finally to remove jQuery from it.

You will follow the same order a careful team would: run the page and record how it behaves, fix the jQuery 4 warnings, close an XSS hole, fix a double-binding bug, separate data from the DOM, and only then translate it to plain JavaScript. Each step is small and testable. This project uses ideas from Reading Legacy jQuery Code, Security: XSS & Safe DOM Building, Events at Scale & Double Binding and Migrating to Plain JavaScript.

The page you inherit

The dashboard loads a list of students with the fee each one still owes, shows the total, lets the accountant search, and has a "Mark paid" button per row that tells the server about the payment. A "Reload" button fetches the list again.

In the examples, a data: URL built from a small array stands in for the real /api/fees.php, and the test server at jsonplaceholder.typicode.com receives the "paid" requests. The data includes one record that the QA team added to test unusual names; look at it closely.

Step 1: run it and record the baseline

Run the legacy page as it is. It loads jQuery 4 with jQuery Migrate, so the old code still runs and every outdated call is logged. A small test script at the bottom (not part of the original code) shows the Migrate warnings and counts the requests. Press "Reload" twice, then search for pune and for paid, then mark one student as paid:

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Fee dashboard (legacy)</title>
  <style>
    td, th { padding: 0.2rem 0.6rem; text-align: start; }
    #alarm { color: #b3261e; font-weight: bold; }
    #warnings { font-size: 0.85rem; color: #555; }
  </style>
</head>
<body>
  <p id="alarm" hidden>Warning: a script from the data ran on this page!</p>
  <p><input id="search" placeholder="Search" aria-label="Search students"> <button id="reload">Reload</button></p>
  <table id="students">
    <thead><tr><th>Student</th><th>City</th><th>Due (Rs.)</th><th></th></tr></thead>
    <tbody></tbody>
  </table>
  <p>Total due: <strong id="total"></strong></p>
  <p id="msg"></p>
  <p>Requests sent so far: <span id="requests">0</span></p>
  <ol id="warnings"></ol>

  <script>
    // Stands in for the server's /api/fees.php
    var STUDENTS = [
      { id: 1, name: "Aarav Sharma", city: "Jaipur", due: 4500 },
      { id: 2, name: "Priya Nair", city: "Kochi", due: 3000 },
      { id: 3, name: "Kabir Singh", city: "Pune", due: 6000 },
      { id: 4, name: "QA test <img src=x onerror=\"document.getElementById('alarm').hidden=false\">", city: "Lucknow", due: 100 },
    ];
    var API = "data:application/json," + encodeURIComponent(JSON.stringify(STUDENTS));
  </script>
  <script src="https://cdn.jsdelivr.net/npm/jquery@4.0.0/dist/jquery.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/jquery-migrate@4.0.2/dist/jquery-migrate.js"></script>
  <script>
    // ===== The original 2013 code =====
    var requests = 0;

    function loadStudents() {
      requests++;
      $("#requests").html(requests);
      $.ajax({
        url: API,
        dataType: "json",
        success: function (data) {
          if (!$.isArray(data)) return;
          var html = "";
          $.each(data, function (i, s) {
            html += "<tr id='row_" + s.id + "'><td>" + s.name + "</td><td>" + s.city + "</td>" +
                    "<td class='due'>" + s.due + "</td><td><button class='pay'>Mark paid</button></td></tr>";
          });
          $("#students tbody").html(html);
          updateTotal();

          $(".pay").click(function () {
            var $btn = $(this);
            var $row = $(this).parent().parent();
            $btn.attr("disabled", "disabled").html("Saving...");
            $.post("https://jsonplaceholder.typicode.com/posts", { id: $row.attr("id").replace("row_", "") }, function () {
              $row.find(".due").html("0");
              $btn.html("Paid");
              updateTotal();
            });
          });
          $("#reload").click(function () { loadStudents(); });
        },
        error: function () { $("#msg").html("Error!"); },
      });
    }

    function updateTotal() {
      var total = 0;
      $("#students td.due").each(function () { total += parseInt($(this).html()); });
      $("#total").html("Rs. " + total);
    }

    $("#search").bind("keyup", function () {
      var q = $.trim($(this).val()).toLowerCase();
      $("#students tbody tr").each(function () {
        $(this).toggle($(this).text().toLowerCase().indexOf(q) > -1);
      });
    });

    $(document).ready(function () { loadStudents(); });
    // ===== End of original code =====

    // Test harness: show Migrate warnings (not part of the page)
    setInterval(function () {
      $("#warnings").empty().append(jQuery.migrateMessages.map(function (m) { return $("<li>").text(m); }));
    }, 1000);
  </script>
</body>
</html>

Now write the behaviour checklist: what the page does today, including the odd parts. This list is your test plan for every later step.

  1. On load, four rows appear and the total shows Rs. 13600.
  2. Search filters rows as you type, ignoring capitals and spaces at the ends. Odd: searching paid matches every row, because the button text "Mark paid" is searched too.
  3. "Mark paid" disables the button, shows "Saving...", then sets the row's due to 0, shows "Paid" and updates the total.
  4. "Reload" fetches the list again. Bug: each reload adds another handler, so after two reloads one click sends several requests (watch the counter).
  5. Bug: the QA record's name runs a script (the red warning appears): stored XSS.
  6. Odd: after a reload, students already marked paid show their old due again, because the fake server never changes. On the real server they would stay at 0.

Write down the Migrate warnings too: jQuery.isArray(), jQuery.trim(), jQuery.fn.bind() and the click() shorthand. Decide now which odd behaviours to keep. Here the team agrees that searching should only look at name and city; everything else must stay the same.

Step 2: fix the Migrate warnings

Change only what Migrate reported, then remove Migrate and run the checklist again. Each fix is one line:

JavaScript
if (!Array.isArray(data)) return;                          // was $.isArray(data)
const q = $(this).val().trim().toLowerCase();              // was $.trim(...)
$("#search").on("keyup", function () { /* ... */ });       // was .bind("keyup", ...)
$(".pay").on("click", function () { /* ... */ });          // was .click(fn)
$("#reload").on("click", loadStudents);                    // was .click(fn), still in the wrong place

The page now runs on jQuery 4 alone. Nothing else changed, so if a checklist item fails, you know exactly which line caused it.

Step 3: close the XSS hole

The row HTML is built by joining strings, so the QA name became a real <img> element with a working onerror attribute. Build the row from elements and put every value in with .text():

JavaScript
function rowFor(s) {
  return $("<tr>", { "data-id": s.id }).append(
    $("<td>").text(s.name),
    $("<td>").text(s.city),
    $("<td>", { class: "due" }).text(s.due),
    $("<td>").append($("<button>", { class: "pay", text: "Mark paid" }))
  );
}
$("#students tbody").empty().append(data.map(rowFor));

Run checklist item 5: the QA name now appears as plain text and the warning stays hidden. The id moved from id='row_3' to data-id, so no string surgery with .replace("row_", "") is needed any more.

Step 4: bind events once

The "Reload" and "Mark paid" handlers were attached inside the success callback, so every load attached them again. Move every binding to the start of the script and delegate the row buttons from the table, which is never replaced:

JavaScript
$("#reload").on("click", loadStudents);                        // once
$("#students").on("click", ".pay", function () { /* ... */ });  // once, works for future rows

Run checklist item 4: reload five times, click once, and the counter goes up by exactly one per load.

Step 5: keep the data in JavaScript, not in the page

The total is calculated by reading numbers back out of table cells with parseInt, and the search hides rows by reading their text. The page has become the database. Keep the data in a state object, and write one render() function that draws the page from it. Every action changes the state, then calls render(). Here is the dashboard after steps 2 to 5, still using jQuery 4, with no Migrate:

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Fee dashboard (jQuery 4, refactored)</title>
  <style>
    td, th { padding: 0.2rem 0.6rem; text-align: start; }
    #alarm { color: #b3261e; font-weight: bold; }
  </style>
</head>
<body>
  <p id="alarm" hidden>Warning: a script from the data ran on this page!</p>
  <p><input id="search" placeholder="Search" aria-label="Search students"> <button id="reload">Reload</button></p>
  <table id="students">
    <thead><tr><th>Student</th><th>City</th><th>Due</th><th></th></tr></thead>
    <tbody></tbody>
  </table>
  <p>Total due: <strong id="total"></strong></p>
  <p id="msg" role="status"></p>
  <p>Requests sent so far: <span id="requests">0</span></p>

  <script>
    const STUDENTS = [
      { id: 1, name: "Aarav Sharma", city: "Jaipur", due: 4500 },
      { id: 2, name: "Priya Nair", city: "Kochi", due: 3000 },
      { id: 3, name: "Kabir Singh", city: "Pune", due: 6000 },
      { id: 4, name: "QA test <img src=x onerror=\"document.getElementById('alarm').hidden=false\">", city: "Lucknow", due: 100 },
    ];
    const API = "data:application/json," + encodeURIComponent(JSON.stringify(STUDENTS));
    const PAY_URL = "https://jsonplaceholder.typicode.com/posts";
  </script>
  <script src="https://cdn.jsdelivr.net/npm/jquery@4.0.0/dist/jquery.min.js"></script>
  <script>
    const state = { students: [], query: "", requests: 0, saving: new Set() };
    const rupees = (n) => "₹" + n.toLocaleString("en-IN");

    function render() {
      const q = state.query.toLowerCase();
      const visible = state.students.filter((s) => (s.name + " " + s.city).toLowerCase().includes(q));
      $("#students tbody").empty().append(visible.map(function (s) {
        const saving = state.saving.has(s.id);
        return $("<tr>", { "data-id": s.id }).append(
          $("<td>").text(s.name),
          $("<td>").text(s.city),
          $("<td>").text(rupees(s.due)),
          $("<td>").append($("<button>", {
            class: "pay",
            text: saving ? "Saving..." : s.due === 0 ? "Paid" : "Mark paid",
            disabled: saving || s.due === 0,
          }))
        );
      }));
      $("#total").text(rupees(state.students.reduce((sum, s) => sum + s.due, 0)));
      $("#requests").text(state.requests);
    }

    function loadStudents() {
      state.requests++;
      render();
      $.getJSON(API)
        .done(function (data) { state.students = data; $("#msg").text(""); })
        .fail(function () { $("#msg").text("Could not load the student list. Please try Reload."); })
        .always(render);
    }

    // Every handler is bound once
    $("#students").on("click", ".pay", function () {
      const id = $(this).closest("tr").data("id");
      const student = state.students.find((s) => s.id === id);
      state.saving.add(id);
      render();
      $.post(PAY_URL, { id: id })
        .done(function () { student.due = 0; })
        .fail(function () { $("#msg").text("Could not save the payment for " + student.name + "."); })
        .always(function () { state.saving.delete(id); render(); });
    });
    $("#search").on("input", function () { state.query = $(this).val().trim(); render(); });
    $("#reload").on("click", loadStudents);

    loadStudents();
  </script>
</body>
</html>

Run the whole checklist again. Searching paid now finds nothing, which is the change the team agreed on; everything else behaves as before, minus the two bugs. Notice how much simpler the "paid" handler became: it changes the data and calls render(), and the button text, the disabled state and the total all follow automatically. A failed save also re-enables the button now, which the old code never did.

Step 6: translate to plain JavaScript

With the logic already separated, removing jQuery is mostly translation with the cheat sheet. Follow the same order each time:

  1. Replace the ready wrapper with a type="module" script, which is deferred automatically.
  2. Replace $("#id") lookups with document.querySelector() once, at the top.
  3. Replace element building with a tiny el() helper based on document.createElement() and textContent.
  4. Replace $.getJSON() and $.post() with fetch(), adding the response.ok check that jQuery did for you.
  5. Replace delegated .on() with one listener and event.target.closest().
  6. Remove the jQuery script tag and run the checklist one last time.
HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Fee dashboard (plain JavaScript)</title>
  <style>
    td, th { padding: 0.2rem 0.6rem; text-align: start; }
    #alarm { color: #b3261e; font-weight: bold; }
  </style>
</head>
<body>
  <p id="alarm" hidden>Warning: a script from the data ran on this page!</p>
  <p><input id="search" placeholder="Search" aria-label="Search students"> <button id="reload">Reload</button></p>
  <table id="students">
    <thead><tr><th>Student</th><th>City</th><th>Due</th><th></th></tr></thead>
    <tbody></tbody>
  </table>
  <p>Total due: <strong id="total"></strong></p>
  <p id="msg" role="status"></p>
  <p>Requests sent so far: <span id="requests">0</span></p>

  <script type="module">
    const STUDENTS = [
      { id: 1, name: "Aarav Sharma", city: "Jaipur", due: 4500 },
      { id: 2, name: "Priya Nair", city: "Kochi", due: 3000 },
      { id: 3, name: "Kabir Singh", city: "Pune", due: 6000 },
      { id: 4, name: "QA test <img src=x onerror=\"document.getElementById('alarm').hidden=false\">", city: "Lucknow", due: 100 },
    ];
    const API = "data:application/json," + encodeURIComponent(JSON.stringify(STUDENTS));
    const PAY_URL = "https://jsonplaceholder.typicode.com/posts";

    // Step 2: look elements up once
    const table = document.querySelector("#students");
    const tbody = table.querySelector("tbody");
    const total = document.querySelector("#total");
    const msg = document.querySelector("#msg");
    const counter = document.querySelector("#requests");

    const state = { students: [], query: "", requests: 0, saving: new Set() };
    const rupees = (n) => "₹" + n.toLocaleString("en-IN");

    // Step 3: a tiny element builder; text always goes in as textContent
    function el(tag, props = {}, ...children) {
      const node = Object.assign(document.createElement(tag), props);
      node.append(...children);                 // strings are inserted as text, never parsed
      return node;
    }

    function render() {
      const q = state.query.toLowerCase();
      const rows = state.students
        .filter((s) => `${s.name} ${s.city}`.toLowerCase().includes(q))
        .map((s) => {
          const saving = state.saving.has(s.id);
          const row = el("tr", {},
            el("td", { textContent: s.name }),
            el("td", { textContent: s.city }),
            el("td", { textContent: rupees(s.due) }),
            el("td", {}, el("button", {
              className: "pay",
              textContent: saving ? "Saving..." : s.due === 0 ? "Paid" : "Mark paid",
              disabled: saving || s.due === 0,
            })));
          row.dataset.id = s.id;
          return row;
        });
      tbody.replaceChildren(...rows);
      total.textContent = rupees(state.students.reduce((sum, s) => sum + s.due, 0));
      counter.textContent = state.requests;
    }

    // Step 4: fetch with an explicit status check
    async function loadStudents() {
      state.requests++;
      render();
      try {
        const response = await fetch(API);
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        state.students = await response.json();
        msg.textContent = "";
      } catch {
        msg.textContent = "Could not load the student list. Please try Reload.";
      }
      render();
    }

    // Step 5: one delegated listener for every "Mark paid" button
    table.addEventListener("click", async (event) => {
      const button = event.target.closest(".pay");
      if (!button) return;
      const id = Number(button.closest("tr").dataset.id);    // dataset values are strings
      const student = state.students.find((s) => s.id === id);
      state.saving.add(id);
      render();
      try {
        const response = await fetch(PAY_URL, { method: "POST", body: new URLSearchParams({ id }) });
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        student.due = 0;
      } catch {
        msg.textContent = `Could not save the payment for ${student.name}.`;
      } finally {
        state.saving.delete(id);
        render();
      }
    });

    document.querySelector("#search").addEventListener("input", (event) => {
      state.query = event.target.value.trim();
      render();
    });
    document.querySelector("#reload").addEventListener("click", loadStudents);

    loadStudents();
  </script>
</body>
</html>

The structure is the same as the jQuery 4 version, line for line. Two translation traps from the migration lesson appeared and were handled: dataset.id is a string (jQuery's .data() converted it to a number), so it is wrapped in Number(); and fetch() does not fail on HTTP errors, so each call checks response.ok.

Step 7: sign off with the checklist

Run the six checklist items one final time on the plain JavaScript version and record the results:

Checklist itemLegacyFinal
1. Load shows four rows and the totalyes (Rs. 13600)yes (₹13,600, agreed new format)
2. Search by name or cityyes, but paid matched every rowyes, name and city only (agreed)
3. Mark paid: saving state, due 0, new totalyes, but a failed save left the button stuckyes, and failures show a message
4. Reload sends one request per clickno, requests multipliedyes
5. Names are shown as textno, stored XSSyes
6. Paid rows after reloaddepends on the serverunchanged

Every difference is either a fixed bug or a change the team agreed to in step 1. That is what a safe refactor looks like: small steps, a written baseline, and no surprises for the people who use the page.

Ideas to extend the project

  • Add an "Unpaid only" checkbox that filters the table through state.
  • Show a "Retry" button after a failed load, using the retry helper from AJAX Patterns: Promises, Errors & Retries.
  • Sort the table by due amount when the column heading is clicked, with aria-sort on the heading.
  • Move the code into an ES module file and write a test for render() with fake state (see the JavaScript course).

Key points

  • Before changing legacy code, run it and write a behaviour checklist that includes odd behaviour and known bugs.
  • Fix in small steps: Migrate warnings first, then security, then event binding, then structure, testing after each.
  • Build DOM from elements with text, never from joined HTML strings.
  • Bind every handler once and delegate from containers that are not replaced.
  • Keep data in a state object and draw the page with one render() function; then removing jQuery is mostly translation.
  • Sign off by comparing old and new behaviour item by item.

Exercise

Extend the plain JavaScript version with an "Unpaid only" checkbox above the table. When it is ticked, only students with a due amount above 0 are shown, and the total still shows the full amount for all students. Keep the rule from the project: the checkbox changes state, and render() draws the page.

Show solution
HTML
<label><input type="checkbox" id="unpaid"> Unpaid only</label>
JavaScript
// 1. Add the flag to the state
const state = { students: [], query: "", requests: 0, saving: new Set(), unpaidOnly: false };

// 2. In render(), add one more filter before .map(...)
const rows = state.students
  .filter((s) => `${s.name} ${s.city}`.toLowerCase().includes(q))
  .filter((s) => !state.unpaidOnly || s.due > 0)
  .map((s) => { /* unchanged */ });

// 3. The checkbox only changes state, then renders
document.querySelector("#unpaid").addEventListener("change", (event) => {
  state.unpaidOnly = event.target.checked;
  render();
});

The total is still calculated from state.students, not from the visible rows, so it always shows the full amount. Because every action goes through state and render(), marking a student as paid while the box is ticked removes that row automatically.

Quick Quiz

1. Why write a behaviour checklist before changing any legacy code?

2. In the legacy page, why did one click on Reload send several requests after a few reloads?

3. Why was the total calculated from state.students instead of the table cells?

4. Which translation trap needed Number() in the plain JavaScript version?

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