Building Reactive UIs Without JavaScript Frameworks

Team 5 min read

#webdev

#vanilla-js

#reactivity

#ui-patterns

Introduction

Reactive user interfaces are about keeping the UI in sync with the underlying state. Frameworks like React, Vue, and Svelte provide abstractions that handle this automatically, but the core ideas behind reactivity can be implemented with plain JavaScript and browser APIs. This post walks through practical patterns, tradeoffs, and a minimal toolkit you can adopt today to build reactive UIs without a framework.

Core idea: state, render, DOM

At the heart of a reactive UI lies a simple loop:

  • State holds the data you care about.
  • A render function reads that state and updates the DOM accordingly.
  • When state changes, the render runs again.

The trick is triggering re-renders efficiently and in a predictable way. Rather than mutating the DOM directly on every event, you batch changes and re-render from a single source of truth.

A minimal reactive store using Proxy

One approachable pattern is to keep a state object behind a Proxy and trigger a render on any relevant change. This keeps the code readable while avoiding frameworks.

// Minimal reactive store
const state = new Proxy({ items: [], count: 0 }, {
  set(target, prop, value) {
    target[prop] = value;
    render();
    return true;
  }
});

function render() {
  const root = document.getElementById('root');
  if (!root) return;
  root.innerHTML = `
    <div>Items: ${state.items.length}</div>
    <ul>${state.items.map(i => `<li>${i}</li>`).join('')}</ul>
  `;
}

// Initial render
render();

// Add item handler (re-assign to trigger render)
document.getElementById('add-btn')?.addEventListener('click', () => {
  const input = document.getElementById('new-item');
  const value = input?.value?.trim();
  if (value) {
    state.items = [...state.items, value]; // reassign to notify
    if (input) input.value = '';
  }
});

Note: Modifying nested structures (like pushing into state.items) won’t automatically trigger render because the Proxy watches property assignments on the state object itself. Replacing the array (as above) is a simple way to trigger a render.

A minimal UI: a simple to-do list

HTML snippet (place where you want the UI):

<div id="root"></div>
<input id="new-item" placeholder="Add item" />
<button id="add-btn">Add</button>

JavaScript (continuing from the previous snippet):

// Ensure the initial render shows a placeholder UI
render();

// The add button logic above will re-render with the new item.

This approach gives you a tiny, predictable reactive loop without any framework. You read from the state in render, and you update the state in response to user events, then re-render.

Encapsulating reactivity with Web Components

Custom elements provide a natural boundary for UI pieces and can encapsulate their own reactive behavior.

class ReactiveList extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.state = { items: [] };
  }

  connectedCallback() {
    this.render();
  }

  set items(val) {
    this.state.items = val;
    this.render();
  }

  get items() {
    return this.state.items;
  }

  render() {
    this.shadowRoot.innerHTML = `
      <ul>${this.state.items.map(i => `<li>${i}</li>`).join('')}</ul>
      <input id="in" placeholder="New item" />
      <button id="btn">Add</button>
    `;
    this.shadowRoot.querySelector('#btn').onclick = () => {
      const val = this.shadowRoot.querySelector('#in').value.trim();
      if (val) this.items = [...this.items, val];
      this.shadowRoot.querySelector('#in').value = '';
    };
  }
}
customElements.define('reactive-list', ReactiveList);

Usage:

<reactive-list></reactive-list>

Custom elements offer a targeted, framework-free way to compose reactive pieces with their own state and lifecycle.

CSS-driven reactivity and progressive enhancement

Not all reactivity needs to be JavaScript-driven. CSS custom properties can reflect state changes, enabling visual updates without re-rendering DOM nodes.

<div id="theme" class="card" aria-label="Theme card">Reactive card</div>
:root { --bg: #fff; --fg: #111; }
.theme-dark { --bg: #111; --fg: #eee; }
.card { background: var(--bg); color: var(--fg); padding: 1rem; border-radius: 6px; }
const root = document.documentElement;
function setTheme(dark) {
  root.classList.toggle('theme-dark', dark);
}
setTheme(false); // light theme by default

In this setup, you can drive visual changes from small state updates, reducing DOM churn for purely stylistic changes.

Performance considerations and patterns

  • Batch updates: Debounce frequent events and reconcile a group of changes in a single render.
  • Diff when possible: If you render large lists, try to only update parts of the DOM that changed, or use a simple diff approach to minimize DOM operations.
  • Stable keys for lists: When rebuilding lists, track items with stable keys to minimize reflow.
  • Avoid frequent full re-renders: Prefer targeted updates to a specific section of the DOM when possible.
  • Use requestAnimationFrame for smooth updates tied to the browser’s repaint cycle.

Accessibility and semantics

  • Use semantic elements (ul, li, button, input) to ensure assistive technologies understand the UI structure.
  • Manage focus when adding or removing items to avoid disorienting users of assistive tech.
  • If content updates dynamically, consider an aria-live region to announce changes for screen-reader users.

Practical patterns recap

  • State-first, render-second: Keep a single source of truth and derive the DOM from it.
  • Proxy-based reactivity for small apps: A lightweight Proxy can trigger renders on state changes without a framework.
  • Component boundaries: Use Web Components to encapsulate reactive behavior and reuse across pages.
  • CSS-driven updates for non-critical visuals: Leverage CSS variables to reflect state without DOM churn.

Conclusion

You can build interactive, reactive user interfaces without a JavaScript framework by embracing a simple unidirectional data flow: state changes trigger renders, and renders reflect the current state in the DOM. Start with a tiny store, a straightforward render function, and gradually introduce encapsulation with Web Components or CSS-driven styling where it makes sense. This approach gives you transparent control over performance, accessibility, and design, while avoiding framework lock-in.