The moment your vanilla JS project gets a bit complex, someone inevitably says: "You should use React for state management." As if the only way to track whether a dropdown is open requires downloading 42MB of node_modules.

Here's the truth: JavaScript has everything you need to manage state. The patterns are old, battle-tested, and criminally underused. Let me show you three approaches, from simple to sophisticated.

Pattern 1: The Simple Store (30 Lines)

This is the Pub/Sub pattern. Components subscribe to state changes and get notified when something updates:

function createStore(initialState) {
  let state = { ...initialState };
  const listeners = new Set();

  return {
    getState: () => ({ ...state }),
    setState(updates) {
      state = { ...state, ...updates };
      listeners.forEach(fn => fn(state));
    },
    subscribe(fn) {
      listeners.add(fn);
      return () => listeners.delete(fn);
    }
  };
}

// Usage
const store = createStore({ count: 0, user: null });

store.subscribe(state => {
  document.getElementById('counter').textContent = state.count;
});

store.setState({ count: store.getState().count + 1 });

That's it. That's Redux in 15 lines. No actions, no reducers, no middleware, no boilerplate. Just state, listeners, and updates.

Pattern 2: Proxy-Based Reactivity (Modern Magic)

JavaScript Proxies let you intercept property access and mutation. This gives you Vue-style reactivity without Vue:

function reactive(target, onChange) {
  return new Proxy(target, {
    set(obj, key, value) {
      obj[key] = value;
      onChange(key, value);
      return true;
    }
  });
}

const state = reactive({ count: 0, theme: 'light' }, (key, value) => {
  console.log(`${key} changed to ${value}`);
  render();
});

// Now just mutate normally — the proxy handles everything
state.count++;        // logs: "count changed to 1"
state.theme = 'dark'; // logs: "theme changed to dark"

You write normal JavaScript. The Proxy watches for changes and triggers re-renders. No setState calls. No immutability headaches. Just... assignment.

Pattern 3: Event-Driven Architecture

For larger apps, use the browser's built-in event system. Custom Events are free, fast, and incredibly powerful:

// State bus — a single EventTarget that coordinates everything
const bus = new EventTarget();

// Component A: emits an event when user logs in
function handleLogin(user) {
  bus.dispatchEvent(new CustomEvent('user:login', { detail: user }));
}

// Component B: listens and reacts
bus.addEventListener('user:login', (e) => {
  document.getElementById('greeting').textContent = `Welcome, ${e.detail.name}`;
});

// Component C: independently also reacts
bus.addEventListener('user:login', (e) => {
  loadUserPreferences(e.detail.id);
});

Components don't know about each other. They just emit and listen to events. This is the same pattern that powers most enterprise Java applications, except it's 10 lines and runs in a browser.

When to Use What

PatternBest ForComplexity
Simple StoreSmall apps, counters, togglesLow
ProxyMedium apps, forms, dashboardsMedium
Event BusLarge apps, decoupled modulesLow (but scales well)

The Real Question

Before reaching for a framework's state management, ask: "Do I actually have a state management problem, or do I just have too many global variables?"

90% of the time, a single object with a few event listeners is all you need. The other 10%? Maybe then consider a library. But try the simple thing first. You might be surprised how far it takes you.