Chapter 9: Vanilla Web Standards - The Framework Was Inside You All Along

What If You Don’t Need Any Framework?

Here’s a thought experiment: What if React, Vue, Svelte, and all the frameworks are solving problems you don’t have?

What if the browser already has everything you need?

“Impossible!” your React brain screams. “I need components! I need state! I need reactivity!”

The browser: “I have components. I have state. I have events.”

Let me introduce you to… the web platform. It’s been here the whole time.

Web Components: No Library Required

class CounterElement extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({ mode: 'open' })
    this.count = 0
  }

  connectedCallback() {
    this.render()
    this.shadowRoot.querySelector('button').addEventListener('click', () => {
      this.count++
      this.render()
    })
  }

  render() {
    this.shadowRoot.innerHTML = `
      <style>
        button {
          padding: 8px 16px;
          font-size: 16px;
          cursor: pointer;
        }
      </style>
      <button>Count: ${this.count}</button>
    `
  }
}

customElements.define('counter-element', CounterElement)

Usage:

<counter-element></counter-element>

No React. No Vue. No Svelte. No build step. No npm install. Just JavaScript and web standards.

It works in every modern browser. Forever. Because it’s a web standard.

querySelector: The Original Component Selection

React has useRef:

function MyComponent() {
  const inputRef = useRef(null)

  useEffect(() => {
    inputRef.current.focus()
  }, [])

  return <input ref={inputRef} />
}

The browser has querySelector:

document.querySelector('input').focus()

That’s it. No ref. No useEffect. No hook rules.

Event Delegation

React re-invents event handling:

<button onClick={handleClick}>Click me</button>

The browser has event listeners:

document.querySelector('button').addEventListener('click', (e) => {
  console.log('Clicked!', e)
})

Or use event delegation for dynamic content:

document.addEventListener('click', (e) => {
  if (e.target.matches('.delete-button')) {
    e.target.closest('.item').remove()
  }
})

One listener handles all delete buttons, even ones added dynamically.

Template Elements: Reusable HTML

React:

function UserCard({ user }) {
  return (
    <div className="card">
      <h3>{user.name}</h3>
      <p>{user.email}</p>
    </div>
  )
}

Vanilla:

<template id="user-card-template">
  <div class="card">
    <h3 class="name"></h3>
    <p class="email"></p>
  </div>
</template>

<script>
  function createUserCard(user) {
    const template = document.getElementById('user-card-template')
    const clone = template.content.cloneNode(true)

    clone.querySelector('.name').textContent = user.name
    clone.querySelector('.email').textContent = user.email

    return clone
  }

  // Usage
  const card = createUserCard({ name: 'Alice', email: 'alice@example.com' })
  document.body.appendChild(card)
</script>

No JSX. No build step. Just HTML and JavaScript.

Fetch API: No Axios Needed

React (with axios):

import axios from 'axios'

function UserProfile() {
  const [user, setUser] = useState(null)

  useEffect(() => {
    axios.get('/api/user').then(res => setUser(res.data))
  }, [])

  // ...
}

Vanilla:

fetch('/api/user')
  .then(res => res.json())
  .then(user => {
    document.querySelector('.user-name').textContent = user.name
  })

The Fetch API is built in. It works great.

Async/Await

async function loadUser() {
  const response = await fetch('/api/user')
  const user = await response.json()

  document.querySelector('.user-name').textContent = user.name
  document.querySelector('.user-email').textContent = user.email
}

loadUser()

Clean. Simple. No library.

FormData: Forms Without State Management

React forms:

function ContactForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    message: ''
  })

  const handleChange = (e) => {
    setFormData({
      ...formData,
      [e.target.name]: e.target.value
    })
  }

  const handleSubmit = (e) => {
    e.preventDefault()
    fetch('/api/contact', {
      method: 'POST',
      body: JSON.stringify(formData)
    })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" value={formData.name} onChange={handleChange} />
      <input name="email" value={formData.email} onChange={handleChange} />
      <textarea name="message" value={formData.message} onChange={handleChange} />
      <button type="submit">Send</button>
    </form>
  )
}

Vanilla:

<form id="contact-form">
  <input name="name" required>
  <input name="email" type="email" required>
  <textarea name="message" required></textarea>
  <button type="submit">Send</button>
</form>

<script>
  document.getElementById('contact-form').addEventListener('submit', async (e) => {
    e.preventDefault()

    const formData = new FormData(e.target)

    await fetch('/api/contact', {
      method: 'POST',
      body: formData
    })

    alert('Sent!')
    e.target.reset()
  })
</script>

No state. No onChange handlers. Just FormData.

Need JSON instead?

const data = Object.fromEntries(new FormData(e.target))

await fetch('/api/contact', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(data)
})

LocalStorage: Client-Side State

React + Redux for storing a theme preference:

// action creators, reducers, store setup...
// 50 lines later...

const theme = useSelector(state => state.theme)
const dispatch = useDispatch()

Vanilla:

// Save
localStorage.setItem('theme', 'dark')

// Load
const theme = localStorage.getItem('theme') || 'light'

// Apply
document.body.classList.toggle('dark-theme', theme === 'dark')

Need reactivity? Dispatch a custom event:

function setTheme(theme) {
  localStorage.setItem('theme', theme)
  window.dispatchEvent(new CustomEvent('theme-changed', { detail: theme }))
}

window.addEventListener('theme-changed', (e) => {
  document.body.classList.toggle('dark-theme', e.detail === 'dark')
})

Intersection Observer: Lazy Loading Without Libraries

React lazy loading:

import { useEffect, useRef, useState } from 'react'
import { useInView } from 'react-intersection-observer' // npm install

function LazyImage({ src }) {
  const { ref, inView } = useInView({ triggerOnce: true })

  return (
    <div ref={ref}>
      {inView && <img src={src} />}
    </div>
  )
}

Vanilla:

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target
      img.src = img.dataset.src
      observer.unobserve(img)
    }
  })
})

document.querySelectorAll('img[data-src]').forEach(img => {
  observer.observe(img)
})
<img data-src="/large-image.jpg" alt="Lazy loaded">

Built into browsers. No library needed.

CSS Variables: Dynamic Styling

React CSS-in-JS:

import styled from 'styled-components'

const Button = styled.button`
  background: ${props => props.theme.primary};
  color: ${props => props.theme.text};
`

Vanilla CSS Variables:

:root {
  --color-primary: #007bff;
  --color-text: #333;
}

button {
  background: var(--color-primary);
  color: var(--color-text);
}

Change them with JavaScript:

document.documentElement.style.setProperty('--color-primary', '#ff0000')

Theme switching:

function setTheme(theme) {
  const root = document.documentElement

  if (theme === 'dark') {
    root.style.setProperty('--color-primary', '#64b5f6')
    root.style.setProperty('--color-text', '#ffffff')
    root.style.setProperty('--color-bg', '#1e1e1e')
  } else {
    root.style.setProperty('--color-primary', '#007bff')
    root.style.setProperty('--color-text', '#333333')
    root.style.setProperty('--color-bg', '#ffffff')
  }
}

No CSS-in-JS library. Just web standards.

Modules: Code Splitting Without Webpack

React code splitting:

const Dashboard = React.lazy(() => import('./Dashboard'))

<Suspense fallback={<Loading />}>
  <Dashboard />
</Suspense>

Vanilla ES Modules:

// main.js
const button = document.querySelector('#load-dashboard')

button.addEventListener('click', async () => {
  const { Dashboard } = await import('./dashboard.js')
  const dashboard = new Dashboard()
  document.body.appendChild(dashboard.render())
})
// dashboard.js
export class Dashboard {
  render() {
    const div = document.createElement('div')
    div.innerHTML = '<h1>Dashboard</h1>'
    return div
  }
}

The browser handles the code splitting. No bundler required.

History API: Routing Without React Router

// Simple router
class Router {
  constructor(routes) {
    this.routes = routes

    window.addEventListener('popstate', () => this.handleRoute())

    document.addEventListener('click', (e) => {
      if (e.target.matches('[data-link]')) {
        e.preventDefault()
        this.navigate(e.target.href)
      }
    })

    this.handleRoute()
  }

  navigate(url) {
    history.pushState(null, null, url)
    this.handleRoute()
  }

  handleRoute() {
    const path = window.location.pathname
    const route = this.routes[path] || this.routes['/404']
    route()
  }
}

// Usage
const router = new Router({
  '/': () => {
    document.querySelector('#app').innerHTML = '<h1>Home</h1>'
  },
  '/about': () => {
    document.querySelector('#app').innerHTML = '<h1>About</h1>'
  },
  '/404': () => {
    document.querySelector('#app').innerHTML = '<h1>Not Found</h1>'
  }
})
<nav>
  <a href="/" data-link>Home</a>
  <a href="/about" data-link>About</a>
</nav>

<div id="app"></div>

Single-page routing. No React Router. 20 lines of code.

Real-World Example: Todo App

The classic. No framework edition.

<!DOCTYPE html>
<html>
<head>
  <style>
    .completed { text-decoration: line-through; opacity: 0.6; }
  </style>
</head>
<body>
  <h1>Todos</h1>

  <form id="add-todo">
    <input name="text" placeholder="What needs to be done?" required>
    <button type="submit">Add</button>
  </form>

  <ul id="todo-list"></ul>

  <template id="todo-template">
    <li>
      <input type="checkbox" class="toggle">
      <span class="text"></span>
      <button class="delete">×</button>
    </li>
  </template>

  <script>
    class TodoApp {
      constructor() {
        this.todos = JSON.parse(localStorage.getItem('todos') || '[]')
        this.render()

        document.getElementById('add-todo').addEventListener('submit', (e) => {
          e.preventDefault()
          this.addTodo(new FormData(e.target).get('text'))
          e.target.reset()
        })

        document.getElementById('todo-list').addEventListener('click', (e) => {
          if (e.target.matches('.delete')) {
            const id = e.target.closest('li').dataset.id
            this.deleteTodo(id)
          }
        })

        document.getElementById('todo-list').addEventListener('change', (e) => {
          if (e.target.matches('.toggle')) {
            const id = e.target.closest('li').dataset.id
            this.toggleTodo(id)
          }
        })
      }

      addTodo(text) {
        this.todos.push({ id: Date.now(), text, completed: false })
        this.save()
        this.render()
      }

      deleteTodo(id) {
        this.todos = this.todos.filter(t => t.id != id)
        this.save()
        this.render()
      }

      toggleTodo(id) {
        const todo = this.todos.find(t => t.id == id)
        todo.completed = !todo.completed
        this.save()
        this.render()
      }

      save() {
        localStorage.setItem('todos', JSON.stringify(this.todos))
      }

      render() {
        const list = document.getElementById('todo-list')
        const template = document.getElementById('todo-template')

        list.innerHTML = ''

        this.todos.forEach(todo => {
          const clone = template.content.cloneNode(true)
          const li = clone.querySelector('li')

          li.dataset.id = todo.id
          li.querySelector('.text').textContent = todo.text
          li.querySelector('.toggle').checked = todo.completed

          if (todo.completed) {
            li.classList.add('completed')
          }

          list.appendChild(clone)
        })
      }
    }

    new TodoApp()
  </script>
</body>
</html>

Full todo app. Add, delete, toggle, persist to localStorage. Zero dependencies. One HTML file.

Try building that in React without npm install.

When Vanilla Makes Sense

Vanilla JavaScript is perfect for:

Vanilla might not be ideal for:

The Performance Story

Let’s compare a simple interactive widget:

React:

Vanilla:

That’s 87x smaller.

First load time:

Vanilla wins every time for simple interactions.

Real Talk: When to Use Vanilla

The web platform has come a long way. Modern browsers support:

You don’t always need a framework.

Sometimes, the framework was inside you (well, inside your browser) all along.


“I spent a month learning React. Then I learned vanilla JavaScript could do most of it in 20 lines. I felt betrayed and enlightened simultaneously.” — A Developer Who Read MDN

Up Next: Chapter 10: Astro - Content Sites Don’t Need Virtual DOMs