Vanilla JS DOM Manipulation Without jQuery
You don’t need jQuery to toggle a modal, highlight a row, or stash an ID on a button. Modern browsers ship with everything required for fast, readable DOM work using just three APIs: querySelector, classList, and dataset. If you’re still reaching for $() in 2026, you’re paying a bundle tax for features you no longer need.moldstud+1
This isn’t about purity for purity’s sake. It’s about smaller bundles, fewer dependencies, and code that’s easier to reason about when you (or your future self) come back to it six months later.
You don't need jQuery to find an element, toggle a class, or read a data attribute. You haven't for about 8 years. If you're still writing $('.btn').addClass('active'), here's the replacement that ships with every browser.
The core swap
$('.item')->document.querySelector('.item')/document.querySelectorAll('.item')addClass / removeClass->element.classListattr('data-id')->element.dataset
That's 90% of what you used jQuery for.
Vanilla JS DOM Manipulation: The 3 APIs You Actually Need
1. querySelector and querySelectorAll
This is your new workhorse. It uses CSS selectors, so your existing knowledge transfers directly.
querySelector tutorial in one line: querySelector returns the first match, querySelectorAll returns all of them as a static NodeList.
// single element
const modal = document.querySelector('#modal');
// multiple - works like you expect, but it's not an array
const buttons = document.querySelectorAll('.btn');
buttons.forEach(btn => {
btn.addEventListener('click', handleClick);
});
// scoped search - only inside the card
const title = card.querySelector('.card__title');
I still forget that querySelectorAll doesn't have map or filter until my code blows up. Quick fix: [...buttons].filter(b => b.disabled).
2. classList - stop string-hacking className
If you've ever done el.className += ' active' and ended up with btn active active, you know the pain.
JavaScript classList is an actual API for it.
const el = document.querySelector('.menu');
el.classList.add('is-open');
el.classList.remove('is-hidden');
el.classList.toggle('is-open'); // returns true/false
// this is the one people miss - forced toggle
el.classList.toggle('is-open', shouldBeOpen);
// check
if (el.classList.contains('is-open')) {
// ...
}
// replace - super handy for state swaps
el.classList.replace('is-loading', 'is-loaded');
No more regex, no more trimming spaces.
3. dataset - your HTML-to-JS bridge
JavaScript dataset reads data-* attributes and handles the camelCase conversion for you. data-user-id becomes dataset.userId. It also writes back to the DOM.
// <button data-user-id="42" data-action="delete">
const btn = document.querySelector('[data-action="delete"]');
console.log(btn.dataset.userId); // "42" - always a string
console.log(btn.dataset.action); // "delete"
// set it
btn.dataset.status = 'pending';
// -> <button data-status="pending">
// destructure for clean code
const { userId, action } = btn.dataset;
Production-grade pattern I use everywhere:
document.addEventListener('click', (e) => {
const trigger = e.target.closest('[data-action]');
if (!trigger) return;
const { action, userId } = trigger.dataset;
if (action === 'delete') {
deleteUser(userId);
}
});
One listener, no jQuery delegation, works for elements added later.
Real-World Gotchas
1. querySelectorAll is static, not live.
getElementsByClassName updates itself when the DOM changes. querySelectorAll doesn't. If you cache const items = document.querySelectorAll('.item') and then add a new .item via JS, items won't include it. Re-query or use event delegation with closest().
2. classList.toggle without a second arg is flaky in UI state.
toggle() flips whatever is there. If you have async code, you can get out of sync. Always use the forced boolean when your state comes from somewhere else: el.classList.toggle('is-active', isActive). It's deterministic.
3. dataset is always a string, and it camelCases.
data-user-id is dataset.userId, not dataset['user-id']. And if you store JSON, you have to parse it yourself: JSON.parse(el.dataset.config). I’ve spent 20 minutes debugging because I compared dataset.count === 2 instead of Number(dataset.count) === 2. Check your types.
What to do next
You don't need to rewrite everything. Next time you reach for jQuery for a toggle or a data attribute, use these three.
The code gets smaller, you drop 87kb, and you stop fighting a library that was solving IE6 problems. Browser APIs caught up.
Comments
No comments yet. Be the first to share your thoughts.