Back to blog

Vanilla JavaScript in 2026: The Return of Pure JS and Why Less is More

Hello HaWkers, an interesting trend is gaining momentum in web development in 2026. Vanilla JavaScript, once dismissed as "too basic," is resurging as a smart choice for developers seeking performance and simplicity.

Let's explore why pure JS is making a comeback and when it makes sense to abandon frameworks.

What Changed

The landscape has changed significantly in recent years:

Factors driving the change:

  • Native browser APIs evolved dramatically
  • Performance became critical (Core Web Vitals, SEO)
  • Framework fatigue reached its peak
  • Dependency maintenance costs exploded
  • Unnecessary complexity in simple projects

💡 Context: In 2020, using vanilla JS was seen as amateurish. In 2026, it's considered a mature technical decision for many cases.

The Power of Modern JavaScript

Native JavaScript in 2026 is incredibly powerful:

Advanced Native APIs

// Modern APIs that eliminate the need for libraries

// 1. Fetch API - Replaced HTTP libraries
const response = await fetch('/api/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'HaWker' }),
  signal: AbortSignal.timeout(5000), // Native timeout!
});

// 2. Web Components - Components without framework
class UserCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        .card { padding: 1rem; border: 1px solid #ddd; }
      </style>
      <div class="card">
        <slot name="name"></slot>
        <slot name="email"></slot>
      </div>
    `;
  }
}
customElements.define('user-card', UserCard);

// 3. Intersection Observer - Native lazy loading
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.src = entry.target.dataset.src;
      observer.unobserve(entry.target);
    }
  });
});

document.querySelectorAll('img[data-src]').forEach(img => {
  observer.observe(img);
});

Modern DOM Manipulation

// DOM manipulation without jQuery or similar

// Powerful selectors
const buttons = document.querySelectorAll('.btn[data-action]');
const form = document.querySelector('#signup-form');

// Efficient event delegation
document.body.addEventListener('click', (e) => {
  const button = e.target.closest('[data-action]');
  if (!button) return;

  const action = button.dataset.action;
  handlers[action]?.(e);
});

// Native templates
const template = document.getElementById('item-template');
const clone = template.content.cloneNode(true);
clone.querySelector('.title').textContent = 'New Item';
document.getElementById('list').appendChild(clone);

// ClassList API
element.classList.add('active', 'visible');
element.classList.remove('hidden');
element.classList.toggle('expanded');
element.classList.replace('old-class', 'new-class');

State Without Framework

Managing state without React/Vue is simpler than you think:

Simple Observer Pattern

// Minimalist reactive state system

class Store {
  #state = {};
  #listeners = new Map();

  constructor(initialState = {}) {
    this.#state = initialState;
  }

  getState() {
    return structuredClone(this.#state);
  }

  setState(updates) {
    const prevState = this.#state;
    this.#state = { ...this.#state, ...updates };

    // Notify only affected listeners
    for (const [key, callbacks] of this.#listeners) {
      if (key in updates && prevState[key] !== updates[key]) {
        callbacks.forEach(cb => cb(updates[key], prevState[key]));
      }
    }
  }

  subscribe(key, callback) {
    if (!this.#listeners.has(key)) {
      this.#listeners.set(key, new Set());
    }
    this.#listeners.get(key).add(callback);

    // Return unsubscribe function
    return () => this.#listeners.get(key).delete(callback);
  }
}

// Usage
const store = new Store({ user: null, cart: [], theme: 'light' });

// Component reacts to changes
store.subscribe('cart', (newCart) => {
  document.getElementById('cart-count').textContent = newCart.length;
});

store.subscribe('theme', (theme) => {
  document.body.classList.toggle('dark', theme === 'dark');
});

// Update state
store.setState({ cart: [...store.getState().cart, newItem] });

Proxy for Reactivity

// Automatic reactivity with Proxy

function createReactive(target, onChange) {
  return new Proxy(target, {
    set(obj, prop, value) {
      const oldValue = obj[prop];
      obj[prop] = value;

      if (oldValue !== value) {
        onChange(prop, value, oldValue);
      }

      return true;
    },

    get(obj, prop) {
      const value = obj[prop];

      // Recursive for nested objects
      if (value && typeof value === 'object') {
        return createReactive(value, onChange);
      }

      return value;
    }
  });
}

// Usage
const state = createReactive(
  { count: 0, user: { name: 'HaWker' } },
  (prop, newVal, oldVal) => {
    console.log(`${prop} changed from ${oldVal} to ${newVal}`);
    updateUI();
  }
);

state.count++; // Triggers automatically
state.user.name = 'Dev'; // Also reactive!

Routing Without Library

SPAs are possible with native APIs:

// Minimalist router with History API

class Router {
  #routes = new Map();
  #notFound = () => {};

  constructor() {
    window.addEventListener('popstate', () => this.#navigate());

    // Intercept links
    document.addEventListener('click', (e) => {
      const link = e.target.closest('a[data-link]');
      if (!link) return;

      e.preventDefault();
      this.push(link.getAttribute('href'));
    });
  }

  route(path, handler) {
    this.#routes.set(path, handler);
    return this;
  }

  notFound(handler) {
    this.#notFound = handler;
    return this;
  }

  push(path) {
    history.pushState(null, '', path);
    this.#navigate();
  }

  #navigate() {
    const path = location.pathname;

    // Try exact match
    if (this.#routes.has(path)) {
      this.#routes.get(path)();
      return;
    }

    // Try match with parameters
    for (const [routePath, handler] of this.#routes) {
      const params = this.#matchRoute(routePath, path);
      if (params) {
        handler(params);
        return;
      }
    }

    this.#notFound();
  }

  #matchRoute(routePath, actualPath) {
    const routeParts = routePath.split('/');
    const actualParts = actualPath.split('/');

    if (routeParts.length !== actualParts.length) return null;

    const params = {};

    for (let i = 0; i < routeParts.length; i++) {
      if (routeParts[i].startsWith(':')) {
        params[routeParts[i].slice(1)] = actualParts[i];
      } else if (routeParts[i] !== actualParts[i]) {
        return null;
      }
    }

    return params;
  }

  start() {
    this.#navigate();
    return this;
  }
}

// Usage
const router = new Router()
  .route('/', () => renderHome())
  .route('/blog', () => renderBlog())
  .route('/blog/:slug', ({ slug }) => renderPost(slug))
  .route('/user/:id/posts', ({ id }) => renderUserPosts(id))
  .notFound(() => render404())
  .start();

Bundle Size Comparison

The impact on bundle size is dramatic:

Approach Bundle Size Parse Time
React + Router + State ~150KB gzip ~200ms
Vue 3 + Router + Pinia ~80KB gzip ~120ms
Vanilla JS (equivalent) ~5KB gzip ~10ms

Real Performance Impact

// Measuring real impact

const performanceComparison = {
  // Time to First Contentful Paint
  fcp: {
    react: '1.2s - 2.5s',
    vue: '0.8s - 1.8s',
    vanilla: '0.3s - 0.6s'
  },

  // Time to Interactive
  tti: {
    react: '2.5s - 4.0s',
    vue: '1.5s - 3.0s',
    vanilla: '0.5s - 1.0s'
  },

  // JavaScript execution time
  jsExecution: {
    react: '150ms - 400ms',
    vue: '80ms - 200ms',
    vanilla: '10ms - 50ms'
  },

  // Memory footprint
  memory: {
    react: '15MB - 30MB',
    vue: '10MB - 20MB',
    vanilla: '3MB - 8MB'
  }
};

When to Use Vanilla JS

Ideal Cases

// Projects where vanilla JS shines

const idealCases = {
  // Landing pages and static sites
  staticSites: {
    reason: 'No need for complex reactivity',
    benefit: 'Maximum performance, better SEO',
    examples: ['Portfolio', 'Corporate', 'Landing page']
  },

  // Widgets and isolated components
  widgets: {
    reason: 'Single component doesn\'t justify framework',
    benefit: 'Minimal bundle, easy integration',
    examples: ['Chat widget', 'Embed form', 'Custom player']
  },

  // Libraries and plugins
  libraries: {
    reason: 'Shouldn\'t force dependencies on users',
    benefit: 'Framework-agnostic, smaller footprint',
    examples: ['SDK', 'Analytics', 'UI components']
  },

  // Projects with performance requirements
  performance: {
    reason: 'Every kilobyte matters',
    benefit: 'Optimized Core Web Vitals',
    examples: ['E-commerce', 'News sites', 'PWAs']
  },

  // Long-lived applications
  longevity: {
    reason: 'Fewer dependencies = fewer breaking changes',
    benefit: 'Simplified maintenance for years',
    examples: ['Internal systems', 'Enterprise tools']
  }
};

Cases Where Framework Still Makes Sense

// When frameworks are justified

const frameworkCases = {
  // Complex apps with lots of state
  complexState: {
    reason: 'Shared state between dozens of components',
    recommendation: 'React, Vue, Svelte'
  },

  // Large teams
  largeTeams: {
    reason: 'Conventions and structure help coordination',
    recommendation: 'Angular, Next.js'
  },

  // Ecosystem needed
  ecosystem: {
    reason: 'Needs framework-specific libraries',
    recommendation: 'Choose by ecosystem'
  },

  // Rapid prototyping
  prototyping: {
    reason: 'Development speed prioritized',
    recommendation: 'Vue, Svelte'
  }
};

Tools for Vanilla JS in 2026

Minimalist Build Tools

// Modern configuration for vanilla JS

// esbuild - Ultra-fast build
// esbuild.config.mjs
import * as esbuild from 'esbuild';

await esbuild.build({
  entryPoints: ['src/main.js'],
  bundle: true,
  minify: true,
  sourcemap: true,
  target: ['es2022'],
  outfile: 'dist/bundle.js',
  // No unnecessary transpilation for modern browsers
  format: 'esm',
});

// Vite for development (without framework)
// vite.config.js
export default {
  build: {
    target: 'esnext',
    minify: 'esbuild',
    rollupOptions: {
      input: 'src/main.js',
    },
  },
  // Hot reload works with vanilla JS!
};

Testing Without Jest

// Native tests with Node.js test runner

import { test, describe, beforeEach } from 'node:test';
import assert from 'node:assert';

import { Store } from './store.js';

describe('Store', () => {
  let store;

  beforeEach(() => {
    store = new Store({ count: 0 });
  });

  test('should initialize with state', () => {
    assert.deepStrictEqual(store.getState(), { count: 0 });
  });

  test('should update state', () => {
    store.setState({ count: 5 });
    assert.strictEqual(store.getState().count, 5);
  });

  test('should notify subscribers', (t) => {
    const callback = t.mock.fn();
    store.subscribe('count', callback);

    store.setState({ count: 10 });

    assert.strictEqual(callback.mock.calls.length, 1);
    assert.deepStrictEqual(callback.mock.calls[0].arguments, [10, 0]);
  });
});

// Run: node --test

Gradual Migration

If you want to experiment, start gradually:

Migration Strategy

// Incremental approach

const migrationStrategy = {
  phase1: {
    action: 'Audit dependencies',
    tasks: [
      'List all libraries used',
      'Identify what can be native',
      'Calculate potential savings'
    ]
  },

  phase2: {
    action: 'Replace utilities',
    tasks: [
      'Remove Lodash (use native methods)',
      'Remove Axios (use Fetch)',
      'Remove Moment (use Intl, Temporal)'
    ]
  },

  phase3: {
    action: 'Isolate components',
    tasks: [
      'Create Web Components for UI',
      'Move logic to ES modules',
      'Reduce framework coupling'
    ]
  },

  phase4: {
    action: 'Evaluate framework',
    tasks: [
      'Measure real state complexity',
      'Consider if framework is necessary',
      'Migrate if benefit is clear'
    ]
  }
};

Example: Replacing Lodash

// Before: Lodash (70KB)
import _ from 'lodash';
const unique = _.uniq(array);
const grouped = _.groupBy(items, 'category');
const debounced = _.debounce(fn, 300);

// After: Native (0KB)
const unique = [...new Set(array)];

const grouped = Object.groupBy(items, item => item.category);
// Or for older browsers:
const grouped = items.reduce((acc, item) => {
  (acc[item.category] ??= []).push(item);
  return acc;
}, {});

const debounced = (fn, delay) => {
  let timeout;
  return (...args) => {
    clearTimeout(timeout);
    timeout = setTimeout(() => fn(...args), delay);
  };
};

The Future

Trends for 2026-2027

What to expect:

  1. More native browser APIs
  2. Web Components more adopted
  3. Smaller and more focused frameworks
  4. "Islands Architecture" mainstream
  5. Ahead-of-time compilation dominant

For Developers

Recommendations:

  • Learn JavaScript deeply, not just frameworks
  • Understand native browser APIs
  • Evaluate real needs before adding dependencies
  • Performance should be a design consideration, not post-optimization

Conclusion

The return of Vanilla JavaScript in 2026 doesn't mean frameworks died. It means we have more options and maturity to choose the right tool for each job.

For simple projects, static sites, widgets, and situations where performance is critical, vanilla JS is often the best choice. For complex applications with lots of shared state and large teams, frameworks still have their place.

The important thing is making conscious choices instead of blindly following trends.

If you want to understand more about development trends, I recommend checking out another article: TypeScript Is the Standard in 2026 where you'll discover how TypeScript dominated the ecosystem.

Let's go! 🦅

Comments (0)

This article has no comments yet 😢. Be the first! 🚀🦅

Add comments