You Probably Don’t Need a Framework
The modern web development industry has convinced itself that every website needs React, Vue, or Angular. We’ve reached a point where a simple restaurant menu requires a build pipeline, state management, and enough JavaScript to power a small application. This isn’t progress — it’s cargo cult programming at scale.
This article examines when frameworks are genuinely necessary versus when they’re expensive solutions to problems you don’t have. We’ll use real performance data, actual bundle sizes, and measurable metrics to make the case — and we’ll be honest where the data is nuanced rather than overstating it.
What You’ll Learn
- The real performance cost of popular frameworks — and what the numbers actually mean
- When vanilla JavaScript significantly outperforms framework-based approaches
- Case studies of major sites that reduced JavaScript dependency for measurable performance gains
- How to build modern, interactive websites without framework overhead
- The difference between complexity and sophistication
- When frameworks are actually justified — yes, there are legitimate use cases
The Framework Fallacy
Let’s start with a frequently cited example that’s worth examining accurately. In 2017, Netflix engineering described switching their logged-out homepage — the marketing page shown to visitors who aren’t signed in — from a React-rendered approach to server-side rendered HTML with vanilla JavaScript for interactivity. The result was a significant reduction in JavaScript payload and faster time-to-interactive for that specific page.
It’s important to note what this wasn’t: Netflix did not replace React across their application. The logged-in streaming interface remained React-based. The lesson is narrower but more useful than the “Netflix ditched React” headline suggests: a content-heavy, mostly static page with modest interactivity does not need a client-side rendering framework, and removing one yields real performance benefits.
That lesson applies to a very large proportion of the web — arguably most of it.
The Bundle Size Reality
Here are the current framework sizes for a production build, minified and gzipped — the figure that reflects what users actually download:
- Vanilla JavaScript: 0kB framework overhead
- Preact (lightweight React alternative): ~4kB
- Vue 3: ~22kB
- React + ReactDOM: ~42–45kB (production build, gzipped)
- Angular: ~75kB+ (minimum, varies significantly with configuration)
React’s ~45kB gzipped is the framework alone — before routing (React Router adds ~13kB), state management (Redux Toolkit adds ~11kB), or any application code. A React application with a minimal feature set commonly ships 100–200kB of JavaScript before you’ve written a line of your own logic.
But raw size is only part of the story. JavaScript is more expensive per kilobyte than any other web asset because it must be parsed, compiled, and executed — not just transferred. A 100kB JavaScript file costs considerably more in CPU time than a 100kB image. On low-to-mid-range mobile devices — which represent the majority of global web traffic — this cost is highly visible as delayed interactivity.
Performance Benchmarks: What the Data Actually Shows
Benchmarks comparing vanilla JavaScript to frameworks consistently show vanilla JS performing faster for simple, isolated DOM operations — often substantially faster for initial render and state updates. The margin varies depending on the benchmark, the task complexity, and the hardware.
It’s worth being honest about what these benchmarks measure: they typically isolate small, repetitive DOM operations where framework overhead is maximally visible and application code is minimally present. In a realistic application — where you’re making network requests, handling errors, managing navigation, and coordinating multiple UI states — the performance gap between vanilla JS and a well-written framework implementation narrows considerably.
The more reliable and practically significant metric is Time to Interactive (TTI): how long before the user can actually use the page. A React application on a mid-range Android device over a 4G connection routinely shows 3–5 seconds before interactivity. A server-rendered HTML page with modest vanilla JavaScript can achieve interactivity within 1 second on the same device and connection. That difference is not a benchmark artefact — it is a genuine, measurable user experience difference that affects bounce rates and task completion.
When Frameworks Actually Make Sense
Before going further, let’s establish when frameworks are genuinely justified — because they are, in specific circumstances.
Complex, highly interactive applications: Applications with hundreds of interconnected UI components that need coordinated, reactive state updates across many parts of the interface simultaneously.
Large development teams: When 20+ developers need to work on the same codebase, frameworks provide architectural guardrails that prevent incompatible approaches from accumulating over time.
Real-time collaboration: Think Google Docs or Figma, where multiple users manipulate shared state simultaneously and the UI must reflect changes from all parties in near real-time.
Heavy data visualisation dashboards: Interfaces with hundreds of live-updating charts and complex user interactions where the reactive model genuinely reduces implementation complexity.
Notice what is not on this list: brochure websites, portfolios, blogs, small business sites, documentation, news sites, or anything that could reasonably be described as “mostly content with some interactivity.” That category describes the vast majority of websites on the web.
The Restaurant Website Reality
Consider a typical small business website — a restaurant with online ordering. Here is what it actually needs:
- Menu display (static content)
- Image gallery (static with lightbox)
- Contact form (validation and submission)
- Online ordering (cart management and checkout)
- Location and hours (static)
React Approach — The Hidden Costs
Initial bundle: ~100–200kB+ (React, ReactDOM, Router, state management)
Build tooling: Webpack or Vite, Babel, numerous transitive dependencies
Development model: JSX, component lifecycle, hooks, state management patterns
Deployment: Build pipeline required; static hosting or Node.js server
Ongoing maintenance: Framework version updates, security patches in dependencies,
library compatibility across major React versions
Vanilla JavaScript Approach
The HTML below works immediately without JavaScript. The cart functionality is layered on top as a progressive enhancement — it does not gate the content:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mario's Pizzeria</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<!-- Semantic HTML that renders and is readable without JavaScript -->
<header>
<h1>Mario's Pizzeria</h1>
<nav aria-label="Main navigation">
<ul>
<li><a href="#menu">Menu</a></li>
<li><a href="#about">About</a></li>
<li><a href="#contact">Contact</a></li>
<li><a href="#order">Order Online</a></li>
</ul>
</nav>
</header>
<main>
<section id="menu">
<h2>Our Menu</h2>
<div class="menu-category">
<h3>Pizzas</h3>
<div class="menu-items">
<div class="menu-item">
<h4>Margherita</h4>
<p>Fresh mozzarella, basil, tomato sauce</p>
<span class="price">£12.99</span>
<button class="add-to-cart"
data-name="Margherita"
data-price="12.99"
type="button">
Add to Cart
</button>
</div>
<!-- Additional menu items follow the same pattern -->
</div>
</div>
</section>
<aside id="cart" class="cart-sidebar" aria-label="Your order" hidden>
<h3>Your Order</h3>
<ul id="cart-items" aria-live="polite"></ul>
<p id="cart-total">Total: £0.00</p>
<a href="/checkout" id="checkout-btn">Proceed to Checkout</a>
</aside>
</main>
<script src="cart.js"></script>
</body>
</html>
// cart.js — complete cart functionality
// Note: item names come from data attributes on your own HTML, but we
// sanitise anyway as a defence-in-depth habit. Never interpolate
// untrusted strings directly into innerHTML.
function escapeHTML(str) {
const div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
class Cart {
constructor() {
try {
this.items = JSON.parse(localStorage.getItem('cart') || '[]');
} catch (e) {
this.items = [];
}
this.cartEl = document.getElementById('cart');
this.cartItemsEl = document.getElementById('cart-items');
this.cartTotalEl = document.getElementById('cart-total');
this.init();
}
init() {
this.renderCart();
this.bindEvents();
}
bindEvents() {
// Single delegated listener on the menu section — no per-button listeners
document.querySelector('.menu-items').addEventListener('click', (e) => {
const btn = e.target.closest('.add-to-cart');
if (!btn) return;
const name = btn.dataset.name;
const price = parseFloat(btn.dataset.price);
if (name && !isNaN(price)) {
this.addItem(name, price);
}
});
}
addItem(name, price) {
const existing = this.items.find(item => item.name === name);
if (existing) {
existing.quantity += 1;
} else {
this.items.push({ name, price, quantity: 1 });
}
this.save();
this.renderCart();
this.showCart();
}
removeItem(index) {
this.items.splice(index, 1);
this.save();
this.renderCart();
if (this.items.length === 0) {
this.hideCart();
}
}
save() {
localStorage.setItem('cart', JSON.stringify(this.items));
}
renderCart() {
const total = this.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
this.cartTotalEl.textContent = 'Total: £' + total.toFixed(2);
if (this.items.length === 0) {
this.cartItemsEl.innerHTML = '<li>Your cart is empty</li>';
return;
}
// Build the list using DOM methods to avoid innerHTML injection risk.
// Alternatively, escapeHTML() on each value is also acceptable.
const fragment = document.createDocumentFragment();
this.items.forEach((item, index) => {
const li = document.createElement('li');
li.className = 'cart-item';
const nameSpan = document.createElement('span');
nameSpan.textContent = item.name + ' \u00d7' + item.quantity;
const priceSpan = document.createElement('span');
priceSpan.textContent = '£' + (item.price * item.quantity).toFixed(2);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.textContent = 'Remove';
removeBtn.addEventListener('click', () => this.removeItem(index));
li.appendChild(nameSpan);
li.appendChild(priceSpan);
li.appendChild(removeBtn);
fragment.appendChild(li);
});
this.cartItemsEl.innerHTML = '';
this.cartItemsEl.appendChild(fragment);
}
showCart() {
this.cartEl.removeAttribute('hidden');
}
hideCart() {
this.cartEl.setAttribute('hidden', '');
}
}
const cart = new Cart();
The Performance Comparison
Vanilla JavaScript approach:
- Total page weight: ~15kB HTML + ~3kB JavaScript + ~5kB CSS — around 23kB
- Content visible and readable without JavaScript
- Interactive shortly after the JavaScript parses — on a fast connection and modern device, this can be well under a second; on a slower 3G mobile connection, expect 1–2 seconds
- No build process required
- Works in every browser released in the last decade
Typical React approach:
- Initial JavaScript payload: 100–200kB+ before application code
- Time to Interactive: 2–5 seconds on a mid-range mobile device on a typical mobile connection
- Blank or minimal page without JavaScript — content is not accessible to users or search engines without JS execution (unless you add server-side rendering, which adds further complexity)
- Build pipeline required
- Dependency management ongoing
For this use case the vanilla approach is substantially smaller and faster to become interactive. The gap is real and measurable — not a theoretical concern.
The Agency Portfolio Trap
Web agencies are particularly prone to framework overuse. A typical agency site needs project showcase filtering, team profiles, a contact form, smooth scrolling, and perhaps a testimonial carousel. None of those require a framework. Here is how to implement the most commonly reached-for ones:
Image Gallery with Lightbox
class Lightbox {
constructor() {
this.current = 0;
this.images = [];
this.overlay = null;
this.boundKeyHandler = this.handleKey.bind(this);
this.init();
}
init() {
document.querySelectorAll('[data-lightbox]').forEach((img, index) => {
this.images.push({ src: img.src, alt: img.alt || '' });
img.addEventListener('click', () => this.open(index));
});
}
open(index) {
this.current = index;
this.overlay = document.createElement('div');
this.overlay.className = 'lightbox-overlay';
this.overlay.setAttribute('role', 'dialog');
this.overlay.setAttribute('aria-modal', 'true');
this.overlay.setAttribute('aria-label', 'Image viewer');
// Build with DOM methods — not innerHTML with interpolated src values
const content = document.createElement('div');
content.className = 'lightbox-content';
this.imgEl = document.createElement('img');
this.imgEl.src = this.images[index].src;
this.imgEl.alt = this.images[index].alt;
const prevBtn = this.createButton('‹', 'Previous image', () => this.prev());
const nextBtn = this.createButton('›', 'Next image', () => this.next());
const closeBtn = this.createButton('×', 'Close', () => this.close());
closeBtn.className = 'lightbox-close';
content.appendChild(this.imgEl);
content.appendChild(prevBtn);
content.appendChild(nextBtn);
content.appendChild(closeBtn);
this.overlay.appendChild(content);
document.body.appendChild(this.overlay);
document.body.style.overflow = 'hidden';
// Close on overlay click (not content click)
this.overlay.addEventListener('click', (e) => {
if (e.target === this.overlay) this.close();
});
document.addEventListener('keydown', this.boundKeyHandler);
closeBtn.focus();
}
createButton(label, ariaLabel, handler) {
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = label;
btn.setAttribute('aria-label', ariaLabel);
btn.addEventListener('click', handler);
return btn;
}
handleKey(e) {
if (e.key === 'ArrowRight') this.next();
if (e.key === 'ArrowLeft') this.prev();
if (e.key === 'Escape') this.close();
}
next() {
this.current = (this.current + 1) % this.images.length;
this.imgEl.src = this.images[this.current].src;
this.imgEl.alt = this.images[this.current].alt;
}
prev() {
this.current = this.current === 0 ? this.images.length - 1 : this.current - 1;
this.imgEl.src = this.images[this.current].src;
this.imgEl.alt = this.images[this.current].alt;
}
close() {
if (this.overlay) {
this.overlay.remove();
this.overlay = null;
}
document.body.style.overflow = '';
document.removeEventListener('keydown', this.boundKeyHandler);
}
}
const lightbox = new Lightbox();
Smooth Scrolling Navigation
// Smooth scrolling that respects the user's motion preferences
document.querySelectorAll('a[href^="#"]').forEach(link => {
link.addEventListener('click', (e) => {
const target = document.querySelector(link.getAttribute('href'));
if (!target) return;
e.preventDefault();
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
target.scrollIntoView({
behavior: prefersReducedMotion ? 'auto' : 'smooth',
block: 'start'
});
// Move focus to the target section for keyboard and screen reader users
if (!target.hasAttribute('tabindex')) {
target.setAttribute('tabindex', '-1');
}
target.focus({ preventScroll: true });
history.pushState(null, '', link.getAttribute('href'));
});
});
Project Filtering
class ProjectFilter {
constructor() {
this.projects = document.querySelectorAll('.project');
this.filters = document.querySelectorAll('.filter-btn');
this.init();
}
init() {
// Single delegated listener rather than one per button
const filterContainer = document.querySelector('.filter-controls');
if (!filterContainer) return;
filterContainer.addEventListener('click', (e) => {
const btn = e.target.closest('.filter-btn');
if (!btn) return;
this.filter(btn.dataset.filter);
});
}
filter(category) {
this.filters.forEach(btn => {
btn.classList.toggle('active', btn.dataset.filter === category);
btn.setAttribute('aria-pressed', btn.dataset.filter === category ? 'true' : 'false');
});
this.projects.forEach(project => {
const show = category === 'all' || project.dataset.category === category;
// CSS handles the animation via the 'visible' class transition
project.classList.toggle('visible', show);
// Use hidden attribute for accessibility — hidden elements are
// skipped by screen readers and excluded from tab order
if (show) {
project.removeAttribute('hidden');
// Allow one frame for display change before adding visible class for transition
requestAnimationFrame(() => project.classList.add('visible'));
} else {
project.classList.remove('visible');
// Wait for CSS transition to complete before hiding
project.addEventListener('transitionend', () => {
if (!project.classList.contains('visible')) {
project.setAttribute('hidden', '');
}
}, { once: true });
}
});
}
}
const projectFilter = new ProjectFilter();
The Enterprise Justification Myth
“But we’re building an enterprise application!” is frequently used to justify framework complexity. The word “enterprise” covers enormous ground. Most enterprise web applications are, at their core, collections of forms, tables, and CRUD operations. Complex? Often. In need of a virtual DOM? Rarely.
Data Table with Sorting — Done Correctly
A common data table implementation mistake in vanilla JS is rebinding event listeners on every render, causing memory leaks and duplicate handler calls. Here’s how to avoid it:
class DataTable {
constructor(selector, data) {
this.table = document.querySelector(selector);
this.data = [...data]; // Don't mutate the original
this.sortColumn = null;
this.sortDirection = 'asc';
this.render();
// Bind events ONCE after initial render, using delegation — not per-cell
this.bindEvents();
}
render() {
if (!this.data.length) {
this.table.innerHTML = '<caption>No data to display</caption>';
return;
}
const headers = Object.keys(this.data[0]);
// Build with DOM methods to avoid innerHTML injection from data values
const thead = document.createElement('thead');
const headerRow = document.createElement('tr');
headers.forEach(header => {
const th = document.createElement('th');
th.scope = 'col';
th.dataset.column = header;
th.setAttribute('aria-sort', 'none');
th.setAttribute('tabindex', '0');
th.textContent = header.charAt(0).toUpperCase() + header.slice(1);
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
const tbody = document.createElement('tbody');
this.data.forEach(row => {
const tr = document.createElement('tr');
headers.forEach(header => {
const td = document.createElement('td');
td.textContent = row[header] != null ? String(row[header]) : '';
tr.appendChild(td);
});
tbody.appendChild(tr);
});
this.table.innerHTML = '';
this.table.appendChild(thead);
this.table.appendChild(tbody);
this.updateSortIndicators();
}
bindEvents() {
// One delegated listener on the table — survives re-renders without rebinding
this.table.addEventListener('click', (e) => {
const th = e.target.closest('th[data-column]');
if (th) this.sort(th.dataset.column);
});
// Keyboard support for header cells
this.table.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
const th = e.target.closest('th[data-column]');
if (th) {
e.preventDefault();
this.sort(th.dataset.column);
}
}
});
}
sort(column) {
if (this.sortColumn === column) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.sortColumn = column;
this.sortDirection = 'asc';
}
this.data.sort((a, b) => {
let valueA = a[column];
let valueB = b[column];
// Numeric comparison if both values are numbers
if (typeof valueA === 'number' && typeof valueB === 'number') {
return this.sortDirection === 'asc' ? valueA - valueB : valueB - valueA;
}
// String comparison as fallback
valueA = String(valueA ?? '').toLowerCase();
valueB = String(valueB ?? '').toLowerCase();
if (valueA < valueB) return this.sortDirection === 'asc' ? -1 : 1;
if (valueA > valueB) return this.sortDirection === 'asc' ? 1 : -1;
return 0;
});
// Re-render tbody only — thead stays, listeners stay, no rebinding needed
const tbody = this.table.querySelector('tbody');
const headers = [...this.table.querySelectorAll('th')].map(th => th.dataset.column);
this.data.forEach((row, i) => {
headers.forEach((header, j) => {
tbody.rows[i].cells[j].textContent = row[header] != null ? String(row[header]) : '';
});
});
this.updateSortIndicators();
}
updateSortIndicators() {
this.table.querySelectorAll('th[data-column]').forEach(th => {
if (th.dataset.column === this.sortColumn) {
th.setAttribute('aria-sort', this.sortDirection === 'asc' ? 'ascending' : 'descending');
} else {
th.setAttribute('aria-sort', 'none');
}
});
}
}
// Usage
const table = new DataTable('#employee-table', employeeData);
Note the key difference from naïve implementations: event listeners are bound once using delegation and never rebound on sort. Calling bindEvents() inside sort() or render() is a memory leak — each call stacks another listener on top of the previous ones, so after five sorts, every click fires six times. Using a single delegated listener on the table avoids this entirely.
The Maintenance Burden
Framework advocates claim that frameworks reduce maintenance burden through consistency and abstraction. This is true in large team contexts. For smaller teams and simpler sites, the reality tends to run in the other direction.
Framework maintenance overhead: Regular framework updates with breaking changes (React 16 → 17 → 18 each introduced changes requiring code updates), hundreds of npm transitive dependencies each carrying their own security surface area, build tool configuration drift, and compatibility issues between libraries as each independently evolves.
Vanilla JavaScript maintenance: Browser APIs are exceptionally stable — code using addEventListener, fetch, and querySelector written ten years ago still runs without modification. Web standards evolve gradually and with extensive backwards compatibility guarantees. Your own code, which you understand completely and which has no third-party dependencies to break under you.
This stability advantage is real, but it comes with a caveat: it requires writing good, well-structured vanilla JavaScript. Poorly structured vanilla code is harder to maintain than a well-structured framework application. The claim isn’t that vanilla is automatically lower maintenance — it’s that it can be, when written with the same discipline you’d apply to any professional codebase.
Building Modern Sites the Right Way
Modern doesn’t mean framework-dependent. ES modules, native browser APIs, and modern CSS handle the vast majority of what developers historically reached for frameworks to achieve.
Module Organisation
// utils/api.js
export class API {
static async request(url, options = {}) {
const response = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...options
});
if (!response.ok) {
throw new Error('HTTP ' + response.status + ': ' + response.statusText);
}
return response.json();
}
static get(url) { return this.request(url); }
static post(url, data) {
return this.request(url, { method: 'POST', body: JSON.stringify(data) });
}
}
// components/modal.js
export class Modal {
static show(title, contentNode) {
// Accept a DOM node for content — not an HTML string — to avoid injection risk
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-labelledby', 'modal-title');
const box = document.createElement('div');
box.className = 'modal-content';
const header = document.createElement('header');
header.className = 'modal-header';
const h2 = document.createElement('h2');
h2.id = 'modal-title';
h2.textContent = title;
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.textContent = '×';
closeBtn.setAttribute('aria-label', 'Close');
closeBtn.addEventListener('click', () => Modal.close(overlay));
const body = document.createElement('div');
body.className = 'modal-body';
body.appendChild(contentNode);
header.appendChild(h2);
header.appendChild(closeBtn);
box.appendChild(header);
box.appendChild(body);
overlay.appendChild(box);
document.body.appendChild(overlay);
document.body.style.overflow = 'hidden';
// Close on overlay background click
overlay.addEventListener('click', (e) => {
if (e.target === overlay) Modal.close(overlay);
});
// ESC key — clean up listener when modal closes
const escHandler = (e) => {
if (e.key === 'Escape') {
Modal.close(overlay);
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
closeBtn.focus();
return overlay;
}
static close(overlay) {
overlay.remove();
document.body.style.overflow = '';
}
}
// main.js
import { API } from './utils/api.js';
import { Modal } from './components/modal.js';
class App {
constructor() {
this.user = null;
this.init();
}
async init() {
await this.loadUserData();
this.bindEvents();
}
async loadUserData() {
try {
this.user = await API.get('/api/user');
} catch (error) {
console.error('Failed to load user data:', error);
}
}
buildSettingsForm() {
const form = document.createElement('form');
form.id = 'settings-form';
const group = document.createElement('div');
group.className = 'form-group';
const label = document.createElement('label');
label.htmlFor = 'settings-name';
label.textContent = 'Name';
const input = document.createElement('input');
input.type = 'text';
input.id = 'settings-name';
input.name = 'name';
input.value = this.user?.name || '';
const btn = document.createElement('button');
btn.type = 'submit';
btn.textContent = 'Save Changes';
group.appendChild(label);
group.appendChild(input);
form.appendChild(group);
form.appendChild(btn);
return form;
}
bindEvents() {
document.getElementById('settings-btn')?.addEventListener('click', () => {
Modal.show('Settings', this.buildSettingsForm());
});
}
}
new App();
Modern CSS for Interactivity
A substantial amount of what JavaScript was once needed for is now handled natively by CSS:
/* Hover interactions — no JavaScript needed */
.card {
transform: translateY(0);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* Responsive grid layout */
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
/* Loading skeleton animation */
.loading {
position: relative;
overflow: hidden;
background: #e0e0e0;
}
.loading::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.5), transparent);
transform: translateX(-100%);
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
to { transform: translateX(100%); }
}
/* CSS :has() for form state — no JavaScript required */
.form-group:has(input:invalid:not(:placeholder-shown)) .error-message {
display: block;
}
/* Scroll-driven animations (modern browsers) */
@keyframes fade-in {
from { opacity: 0; transform: translateY(1rem); }
to { opacity: 1; transform: translateY(0); }
}
.animate-on-scroll {
animation: fade-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 30%;
}
Scroll-driven animations, container queries, :has(), and native CSS transitions eliminate entire categories of JavaScript that were once considered essential. Check browser support tables before relying on the newer features in production, but the direction of travel is clear: the browser platform is becoming more capable, and the need for JavaScript to fill platform gaps continues to shrink.
When Frameworks Win
It would be intellectually dishonest to argue that frameworks never provide value. Here is an example that genuinely benefits from framework architecture:
// A collaborative document editor — this level of complexity
// is where framework architecture earns its keep.
class DocumentEditor {
constructor() {
this.state = {
document: new DocumentModel(),
users: new Map(),
selections: new Map(),
operations: new OperationQueue(),
conflicts: new ConflictResolver()
};
this.websocket = new CollaborationSocket();
this.websocket.on('operation', this.handleRemoteOperation.bind(this));
this.websocket.on('user-join', this.handleUserJoin.bind(this));
this.websocket.on('user-leave', this.handleUserLeave.bind(this));
// Dozens more event handlers, each triggering coordinated state updates
}
handleRemoteOperation(operation) {
const transformed = this.state.conflicts.transform(operation);
this.state.document.apply(transformed);
this.updateView(transformed.affectedRange);
this.reconcileSelections();
this.broadcastCursorPositions();
// ... cascading state updates across dozens of UI components
}
}
When you have dozens of components whose state depends on each other, real-time updates arriving from multiple users, and complex transformation logic — the reactive model that frameworks provide becomes genuinely valuable. The overhead is justified because it is solving a real problem rather than adding complexity to something simple.
Making the Right Choice
Choose vanilla JavaScript when:
- Building content-focused websites where performance and accessibility are priorities
- The team is small to medium and will own the codebase long-term
- Long-term maintainability matters more than short-term development velocity
- You need the site to work without JavaScript (or work better without it)
- Budget or infrastructure constraints make build pipelines and dependency management a burden
Choose a framework when:
- Building genuinely complex interactive applications — not just “has a contact form” but real application complexity with deeply coordinated state
- Managing large development teams that need architectural guardrails
- Building real-time collaborative features where reactive state management solves a real problem
- Time-to-market velocity matters more than initial performance, and you have a plan to address performance later
Conclusion: Sophisticated Simplicity
The web industry has confused complexity with sophistication. Building a restaurant website with React isn’t sophisticated — it’s wasteful. True sophistication lies in choosing the right tool for the job and using the minimum necessary complexity to solve the actual problem.
Vanilla JavaScript isn’t a step backward. It’s a conscious choice to prioritise user experience over developer convenience, performance over popularity, and long-term maintainability over short-term familiarity.
The qualifier matters: this requires writing vanilla JavaScript well. Poorly structured vanilla code with inline event handlers, global variables, and no clear architecture is not better than a well-structured React application. The argument for vanilla JS is an argument for craftsmanship, not for casualness.
Your users don’t care about your technical stack. They care about fast, reliable, accessible experiences. For the vast majority of websites on the web — content sites, portfolios, small business sites, documentation, marketing pages — vanilla JavaScript delivers that more effectively than any framework currently does.
The question isn’t whether you can build it with React. The question is whether you should. For most websites, honestly examined, the answer is no.
Stop building monuments to complexity. Start building websites that work.
