Vanilla JS State Management for Advanced Apps

Build a full state management system in vanilla JavaScript. Learn how to centralize state, derive computed values, handle async actions, and connect to the DOM without a framework.

8 min read

State management is the practice of keeping your application's data in one predictable place and letting the UI react when that data changes. Instead of tracking variables across files, you centralize state in a store and let every part of your app read from and write to that single source.

Frameworks like React, Vue, and Svelte each give you a state management story. But the concepts are universal. This article builds a complete vanilla JavaScript state management system so you understand exactly what happens under the hood.

The Core Store

Start with the reactive store from the observer pattern. It holds state, notifies subscribers, and enforces that state is never mutated directly:

javascriptjavascript
function createStore(initialState) {
  let state = { ...initialState };
  const listeners = [];
 
  return {
    getState() {
      return state;
    },
 
    setState(partial) {
      const prev = state;
      state = { ...state, ...partial };
 
      listeners.forEach(fn => {
        try {
          fn(state, prev);
        } catch (err) {
          console.error("Store subscriber error:", err);
        }
      });
    },
 
    subscribe(fn) {
      listeners.push(fn);
      fn(state, state); // Initial call
      return () => {
        const idx = listeners.indexOf(fn);
        if (idx !== -1) listeners.splice(idx, 1);
      };
    }
  };
}

Every state update goes through setState. Every subscriber gets the new and previous state. The unsubscribe return value prevents memory leaks.

Store state flow: actions update state, subscribers react

Actions flow into the store. The store creates a new state object. Every subscriber receives the update and decides how to react. No component directly touches another component.

Computed Properties

State should not store derived values. If todos is an array in state, the count of active todos should be computed, not stored:

javascriptjavascript
function createStore(initialState) {
  let state = { ...initialState };
  const listeners = [];
  const computed = {};
 
  return {
    getState() {
      return state;
    },
 
    setState(partial) {
      const prev = state;
      state = { ...state, ...partial };
      listeners.forEach(fn => fn(state, prev));
    },
 
    // Register a computed value that derives from state
    defineComputed(name, computeFn) {
      computed[name] = computeFn;
    },
 
    // Read a computed value
    get(name) {
      if (name in computed) {
        return computed[name](state);
      }
      return state[name];
    },
 
    subscribe(fn) {
      listeners.push(fn);
      fn(state, state);
      return () => {
        const idx = listeners.indexOf(fn);
        if (idx !== -1) listeners.splice(idx, 1);
      };
    }
  };
}

Usage:

javascriptjavascript
const store = createStore({
  todos: [
    { id: 1, text: "Learn state management", completed: false },
    { id: 2, text: "Build a demo app", completed: true }
  ]
});
 
store.defineComputed("activeCount", (state) =>
  state.todos.filter(t => !t.completed).length
);
 
store.defineComputed("completedCount", (state) =>
  state.todos.filter(t => t.completed).length
);
 
store.defineComputed("isEmpty", (state) =>
  state.todos.length === 0
);
 
console.log(store.get("activeCount"));    // 1
console.log(store.get("completedCount")); // 1
console.log(store.get("isEmpty"));        // false

The rule: if a value can be calculated from existing state, do not store it. Compute it instead. This eliminates an entire class of bugs where derived state gets out of sync with source state.

Actions as Named Operations

Raw setState calls scattered across your app make it hard to understand what the app can do. Wrap state changes in named action functions:

javascriptjavascript
function createTodoStore() {
  const store = createStore({
    todos: [],
    filter: "all"
  });
 
  // Computed values
  store.defineComputed("filteredTodos", (state) => {
    if (state.filter === "active") {
      return state.todos.filter(t => !t.completed);
    }
    if (state.filter === "completed") {
      return state.todos.filter(t => t.completed);
    }
    return state.todos;
  });
 
  store.defineComputed("stats", (state) => ({
    total: state.todos.length,
    active: state.todos.filter(t => !t.completed).length,
    completed: state.todos.filter(t => t.completed).length
  }));
 
  // Actions -- the only way to change state
  return {
    ...store,
 
    addTodo(text) {
      const todo = { id: Date.now(), text: text.trim(), completed: false };
      if (!todo.text) return;
      store.setState({
        todos: [...store.getState().todos, todo]
      });
    },
 
    toggleTodo(id) {
      store.setState({
        todos: store.getState().todos.map(t =>
          t.id === id ? { ...t, completed: !t.completed } : t
        )
      });
    },
 
    removeTodo(id) {
      store.setState({
        todos: store.getState().todos.filter(t => t.id !== id)
      });
    },
 
    setFilter(filter) {
      store.setState({ filter });
    },
 
    clearCompleted() {
      store.setState({
        todos: store.getState().todos.filter(t => !t.completed)
      });
    }
  };
}

Now the app never calls setState directly. Every state change is a named action. This gives you a complete audit trail. Add console.log to every action, and you can trace exactly what the user did.

This is the same pattern libraries like Redux formalize. Actions describe what happened. The store determines how state changes. See the pub-sub event bus pattern for another approach to decoupled communication.

Middleware: Cross-Cutting Concerns

Middleware runs before or after every state change. Use it for logging, persistence, or async side effects:

javascriptjavascript
function applyMiddleware(store, ...middlewares) {
  const originalSetState = store.setState.bind(store);
 
  store.setState = (partial) => {
    const prev = store.getState();
    const next = { ...prev, ...partial };
 
    // Run before-middleware
    const shouldContinue = middlewares.every(mw => {
      if (mw.before) return mw.before(prev, next);
      return true;
    });
 
    if (!shouldContinue) return;
 
    originalSetState(partial);
 
    // Run after-middleware
    middlewares.forEach(mw => {
      if (mw.after) mw.after(prev, store.getState());
    });
  };
 
  return store;
}

Here are three practical middleware:

javascriptjavascript
// Log every state change
const logger = {
  before(prev, next) {
    console.group("State Change");
    console.log("Previous:", prev);
    console.log("Next:", next);
    console.groupEnd();
    return true;
  }
};
 
// Persist state to localStorage
const persist = {
  after(prev, next) {
    localStorage.setItem("app-state", JSON.stringify(next));
  }
};
 
// Prevent duplicate state updates
const dedupe = {
  before(prev, next) {
    const changed = Object.keys(next).some(
      key => prev[key] !== next[key]
    );
    return changed; // false = skip the update
  }
};
 
const store = applyMiddleware(
  createTodoStore(),
  logger,
  persist,
  dedupe
);

Middleware is the secret to keeping your store simple. Instead of adding logging, persistence, and validation inside every action, add them as middleware. Each middleware does one thing. They compose together.

Connecting to the DOM

The final piece is rendering. Each subscriber updates a specific part of the DOM based on state:

javascriptjavascript
// Store setup
const app = createTodoStore();
 
// Render the todo list
app.subscribe((state) => {
  const listEl = document.getElementById("todo-list");
  const todos = app.get("filteredTodos");
 
  if (todos.length === 0) {
    listEl.innerHTML = "<p class='empty'>No todos to show</p>";
    return;
  }
 
  listEl.innerHTML = todos.map(todo => `
    <li class="${todo.completed ? 'completed' : ''}">
      <span>${escapeHtml(todo.text)}</span>
      <button data-action="toggle" data-id="${todo.id}">
        ${todo.completed ? 'Undo' : 'Done'}
      </button>
      <button data-action="remove" data-id="${todo.id}">Delete</button>
    </li>
  `).join("");
});
 
// Render stats
app.subscribe((state) => {
  const stats = app.get("stats");
  document.getElementById("stats").textContent =
    `${stats.total} total, ${stats.active} active, ${stats.completed} completed`;
});
 
// Render filter buttons
app.subscribe((state) => {
  document.querySelectorAll("[data-filter]").forEach(btn => {
    btn.classList.toggle("active", btn.dataset.filter === state.filter);
  });
});

Notice the rendering functions never call setState. They are pure: state in, DOM out. Actions are the only place that changes state. This separation is the essence of predictable UI architecture.

Undo and Redo with State History

Because every state change goes through setState, you can track history:

javascriptjavascript
function createStoreWithHistory(initialState) {
  const store = createStore(initialState);
  const history = [{ ...initialState }];
  let cursor = 0;
 
  const originalSetState = store.setState.bind(store);
  store.setState = (partial) => {
    const prev = store.getState();
    const next = { ...prev, ...partial };
 
    // Discard any future states if we are in the middle of history
    history.splice(cursor + 1);
    history.push(next);
    cursor = history.length - 1;
 
    // Keep a reasonable history size
    if (history.length > 50) {
      history.shift();
      cursor -= 1;
    }
 
    originalSetState(partial);
  };
 
  return {
    ...store,
    undo() {
      if (cursor <= 0) return;
      cursor -= 1;
      store.setState(history[cursor]);
    },
    redo() {
      if (cursor >= history.length - 1) return;
      cursor += 1;
      store.setState(history[cursor]);
    },
    canUndo() {
      return cursor > 0;
    },
    canRedo() {
      return cursor < history.length - 1;
    }
  };
}

Every setState call pushes a snapshot onto the history stack. Undo moves the cursor back. Redo moves it forward. This works because state is always replaced, never mutated.

Common Mistakes

Storing derived data. If activeCount can be computed from todos, do not put it in state. Duplicate state inevitably gets out of sync.

Mutating state directly. Never do state.todos.push(item). Always create a new array with [...state.todos, item]. Direct mutation bypasses subscribers.

Calling setState inside a subscriber. This creates cascading updates. If subscriber A's render triggers setState, subscriber B fires, which triggers setState again. Keep subscribers pure.

Too much in state. Only put shared application state in the store. Local UI state like "is this dropdown open" belongs in the component, not the store. See the observer pattern for component-level reactivity.

Rune AI

Rune AI

Key Insights

  • A centralized store holds all application state in one place.
  • State is never mutated directly. setState creates a new state object.
  • Subscribers re-render only the parts of the UI that depend on changed state.
  • Computed properties derive values from state without storing them twice.
  • Middleware adds cross-cutting concerns like logging, persistence, and async handling.
RunePowered by Rune AI

Frequently Asked Questions

Why build state management from scratch instead of using Redux or Zustand?

Building it yourself teaches you the core concepts every library is built on: a single state tree, immutable updates, subscriber notifications, and computed values. Once you understand those, any library becomes easier to learn.

Can this replace a framework's state management?

For small to medium apps, yes. For large production apps, use a library that handles edge cases like middleware, devtools, and performance optimizations. The patterns you learn here transfer directly.

How is this different from the reactive store in the observer article?

This article extends the basic reactive store with middleware, computed properties, action creators, and history/undo. It is the next step after understanding the observer-based reactive store.

Conclusion

State management is the practice of keeping your application state in one predictable place and letting the UI react to changes. Build a store, add computed values, handle side effects with middleware, and connect it to the DOM. The patterns you build here are the foundation of every state management library.State management boils down to three rules: centralize state in one place, change it only through defined actions, and let subscribers react to changes. Everything else -- computed values, middleware, history -- builds on that foundation. The patterns in this article are not theoretical. They are exactly how Redux, Zustand, Pinia, and every other state library works. The difference is that libraries add devtools, performance optimizations, and framework integrations on top. The core concepts are what you just built.