Audit first
Name the behavior before the rewrite
Track selectors, delegated events, aria state, and DOM writes so the refactor has a visible contract before implementation moves.
Migration playbook
Use these patterns when you want smaller, reversible steps instead of a full rewrite. Each section turns a common migration concern into an implementation pattern you can copy.
Audit first
Capture selectors, events, and state writes before you move a line of code.
Refactor in slices
Replace one behavior at a time, then validate it before touching the next dependency.
Keep the reference page open alongside this guide when you need the exact DOM API equivalent for a helper you are removing.
A successful jQuery migration is less about rewriting everything and more about shrinking uncertainty one behavior at a time.
Use this page when the reference tells you which native API to use, but you still need a safe pattern for applying it inside an existing codebase.
Who this helps
Teams modernising legacy UI without stopping feature work or re-platforming the whole frontend.
How to use it
Pick one risk area, apply the pattern, validate it in production-like conditions, then move to the next slice.
Goal
Make each migration step explicit enough that code review, QA, and rollback remain straightforward.
Treat every removal of jQuery as a behavior audit first. Capture the existing contract, then refactor the implementation behind that contract.
Before changing an implementation, list the selectors, delegated events, data attributes, and stateful class names a component already relies on. This gives you a concrete definition of “done” for the refactor.
const navContract = {
root: '#sidebar',
selectors: ['.nav-link', '[data-section-id]'],
delegatedEvents: ['click', 'keydown'],
reads: ['dataset.sectionId', 'textContent'],
writes: ['classList', 'aria-expanded'],
};
Once the behavior is mapped, move the code toward smaller named steps. The browser APIs are already there; the main work is making the flow obvious.
Long jQuery chains hide collection boundaries. Split them into named arrays so the query, the filtering logic, and the mutation are easy to inspect independently.
$('#sidebar .nav-link')
.not('.is-disabled')
.slice(0, 4)
.addClass('is-ready');
const readyLinks = Array.from(document.querySelectorAll('#sidebar .nav-link'))
.filter((link) => !link.classList.contains('is-disabled'))
.slice(0, 4);
readyLinks.forEach((link) => {
link.classList.add('is-ready');
});
When several handlers touch the same DOM state, move that state transition into one helper. Named helpers are easier to review than repeated chains spread across multiple callbacks.
function setPanelExpanded(panel, isExpanded) {
panel.classList.toggle('is-open', isExpanded);
panel.setAttribute('aria-hidden', String(!isExpanded));
}
function setButtonExpanded(button, isExpanded) {
button.classList.toggle('is-active', isExpanded);
button.setAttribute('aria-expanded', String(isExpanded));
}
Most delegated jQuery handlers map cleanly to one native listener on the container plus a
closest() guard for the actionable
element.
$('#results').on('click', '.remove-button', function () {
$(this).closest('.result').remove();
});
const results = document.querySelector('#results');
results?.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
const button = target.closest('.remove-button');
if (!button) return;
button.closest('.result')?.remove();
});
The native fetch() API is usually enough, but it is more
explicit: you check response.ok,
decide how to parse the body, and surface errors intentionally.
$.ajax({
url: '/api/profile',
method: 'GET',
dataType: 'json',
success(data) {
renderProfile(data);
},
error() {
showError();
},
});
async function loadProfile() {
const response = await fetch('/api/profile');
if (!response.ok) {
throw new Error(`Profile request failed: ${response.status}`);
}
const data = await response.json();
renderProfile(data);
}
loadProfile().catch(showError);
If a migration spans many files, a tiny adapter layer can reduce churn. Keep it narrow and temporary so the team does not just rebuild jQuery under a different name.
export const dom = {
qs: (selector, scope = document) => scope.querySelector(selector),
qsa: (selector, scope = document) => Array.from(scope.querySelectorAll(selector)),
on: (target, type, handler, options) => target.addEventListener(type, handler, options),
};
Adapters work best when they encode your team’s conventions, not when they try to preserve every legacy chain exactly.
The last step is operational: verify the refactor with the same discipline you would apply to a feature release.
A migration is only complete when behavior, accessibility, and cleanup all survive review. Keep the checklist short enough that teams will actually use it.
Migration checklist
- selectors still match the rendered HTML
- delegated events still work for dynamic content
- aria-* state matches visual state
- removed jQuery import is no longer needed
- regression test or manual smoke test was updated