JavaScript MVC Architecture: Complete Guide

A complete guide to JavaScript MVC architecture. Covers Model-View-Controller separation, data binding between layers, event-driven communication, controller routing, view rendering strategies, and comparing MVC with modern alternatives.

JavaScriptadvanced
17 min read

Model-View-Controller (MVC) separates application concerns into three interconnected layers: Models manage data and business logic, Views handle presentation, and Controllers coordinate between them. Understanding MVC in vanilla JavaScript clarifies how modern frameworks work under the hood.

For building full applications with MVC, see Building Vanilla JS Apps with MVC Architecture.

The Model Layer

javascriptjavascript
class EventEmitter {
  #listeners = new Map();
 
  on(event, handler) {
    if (!this.#listeners.has(event)) this.#listeners.set(event, []);
    this.#listeners.get(event).push(handler);
    return () => this.off(event, handler);
  }
 
  off(event, handler) {
    const handlers = this.#listeners.get(event);
    if (handlers) {
      const idx = handlers.indexOf(handler);
      if (idx > -1) handlers.splice(idx, 1);
    }
  }
 
  emit(event, ...args) {
    (this.#listeners.get(event) || []).forEach((h) => h(...args));
  }
}
 
class TodoModel extends EventEmitter {
  #todos = [];
  #nextId = 1;
  #filter = "all";
 
  addTodo(text) {
    if (!text.trim()) throw new Error("Todo text cannot be empty");
 
    const todo = {
      id: this.#nextId++,
      text: text.trim(),
      completed: false,
      createdAt: Date.now(),
    };
 
    this.#todos.push(todo);
    this.emit("todoAdded", todo);
    this.emit("changed", this.getFilteredTodos());
    return todo;
  }
 
  toggleTodo(id) {
    const todo = this.#todos.find((t) => t.id === id);
    if (!todo) throw new Error(`Todo ${id} not found`);
 
    todo.completed = !todo.completed;
    this.emit("todoToggled", todo);
    this.emit("changed", this.getFilteredTodos());
    return todo;
  }
 
  removeTodo(id) {
    const idx = this.#todos.findIndex((t) => t.id === id);
    if (idx === -1) throw new Error(`Todo ${id} not found`);
 
    const [removed] = this.#todos.splice(idx, 1);
    this.emit("todoRemoved", removed);
    this.emit("changed", this.getFilteredTodos());
    return removed;
  }
 
  updateTodo(id, text) {
    const todo = this.#todos.find((t) => t.id === id);
    if (!todo) throw new Error(`Todo ${id} not found`);
 
    todo.text = text.trim();
    this.emit("todoUpdated", todo);
    this.emit("changed", this.getFilteredTodos());
    return todo;
  }
 
  setFilter(filter) {
    if (!["all", "active", "completed"].includes(filter)) {
      throw new Error(`Invalid filter: ${filter}`);
    }
    this.#filter = filter;
    this.emit("filterChanged", filter);
    this.emit("changed", this.getFilteredTodos());
  }
 
  getFilteredTodos() {
    switch (this.#filter) {
      case "active":
        return this.#todos.filter((t) => !t.completed);
      case "completed":
        return this.#todos.filter((t) => t.completed);
      default:
        return [...this.#todos];
    }
  }
 
  getStats() {
    return {
      total: this.#todos.length,
      active: this.#todos.filter((t) => !t.completed).length,
      completed: this.#todos.filter((t) => t.completed).length,
      filter: this.#filter,
    };
  }
 
  clearCompleted() {
    this.#todos = this.#todos.filter((t) => !t.completed);
    this.emit("changed", this.getFilteredTodos());
  }
}

The View Layer

javascriptjavascript
class TodoView {
  #container;
  #input;
  #list;
  #stats;
  #filterBtns;
 
  constructor(containerSelector) {
    this.#container = document.querySelector(containerSelector);
    this.#render();
    this.#cacheElements();
  }
 
  #render() {
    this.#container.innerHTML = `
      <div class="todo-app">
        <h1>Todo MVC</h1>
        <form class="todo-form">
          <input type="text" class="todo-input" placeholder="What needs to be done?" autofocus>
          <button type="submit">Add</button>
        </form>
        <ul class="todo-list"></ul>
        <div class="todo-footer">
          <span class="todo-stats"></span>
          <div class="todo-filters">
            <button data-filter="all" class="active">All</button>
            <button data-filter="active">Active</button>
            <button data-filter="completed">Completed</button>
          </div>
          <button class="clear-completed">Clear completed</button>
        </div>
      </div>
    `;
  }
 
  #cacheElements() {
    this.#input = this.#container.querySelector(".todo-input");
    this.#list = this.#container.querySelector(".todo-list");
    this.#stats = this.#container.querySelector(".todo-stats");
    this.#filterBtns = this.#container.querySelectorAll("[data-filter]");
  }
 
  displayTodos(todos) {
    this.#list.innerHTML = todos
      .map(
        (todo) => `
      <li class="todo-item ${todo.completed ? "completed" : ""}" data-id="${todo.id}">
        <input type="checkbox" class="toggle" ${todo.completed ? "checked" : ""}>
        <span class="todo-text">${this.#escapeHtml(todo.text)}</span>
        <button class="edit-btn" aria-label="Edit">Edit</button>
        <button class="delete-btn" aria-label="Delete">x</button>
      </li>
    `
      )
      .join("");
  }
 
  displayStats(stats) {
    this.#stats.textContent = `${stats.active} item${stats.active !== 1 ? "s" : ""} left`;
  }
 
  setActiveFilter(filter) {
    this.#filterBtns.forEach((btn) => {
      btn.classList.toggle("active", btn.dataset.filter === filter);
    });
  }
 
  getInputValue() {
    return this.#input.value;
  }
 
  clearInput() {
    this.#input.value = "";
    this.#input.focus();
  }
 
  // Event binding methods for the controller
  bindAddTodo(handler) {
    const form = this.#container.querySelector(".todo-form");
    form.addEventListener("submit", (e) => {
      e.preventDefault();
      const text = this.getInputValue();
      if (text) handler(text);
    });
  }
 
  bindToggleTodo(handler) {
    this.#list.addEventListener("change", (e) => {
      if (e.target.classList.contains("toggle")) {
        const id = Number(e.target.closest("[data-id]").dataset.id);
        handler(id);
      }
    });
  }
 
  bindDeleteTodo(handler) {
    this.#list.addEventListener("click", (e) => {
      if (e.target.classList.contains("delete-btn")) {
        const id = Number(e.target.closest("[data-id]").dataset.id);
        handler(id);
      }
    });
  }
 
  bindFilterChange(handler) {
    this.#container.querySelector(".todo-filters").addEventListener("click", (e) => {
      if (e.target.dataset.filter) {
        handler(e.target.dataset.filter);
      }
    });
  }
 
  bindClearCompleted(handler) {
    this.#container.querySelector(".clear-completed").addEventListener("click", handler);
  }
 
  #escapeHtml(text) {
    const div = document.createElement("div");
    div.textContent = text;
    return div.innerHTML;
  }
}

The Controller

javascriptjavascript
class TodoController {
  #model;
  #view;
 
  constructor(model, view) {
    this.#model = model;
    this.#view = view;
 
    // Bind view events to controller methods
    this.#view.bindAddTodo(this.handleAddTodo.bind(this));
    this.#view.bindToggleTodo(this.handleToggleTodo.bind(this));
    this.#view.bindDeleteTodo(this.handleDeleteTodo.bind(this));
    this.#view.bindFilterChange(this.handleFilterChange.bind(this));
    this.#view.bindClearCompleted(this.handleClearCompleted.bind(this));
 
    // Bind model events to view updates
    this.#model.on("changed", (todos) => {
      this.#view.displayTodos(todos);
      this.#view.displayStats(this.#model.getStats());
    });
 
    this.#model.on("filterChanged", (filter) => {
      this.#view.setActiveFilter(filter);
    });
 
    // Initial render
    this.#view.displayTodos(this.#model.getFilteredTodos());
    this.#view.displayStats(this.#model.getStats());
  }
 
  handleAddTodo(text) {
    try {
      this.#model.addTodo(text);
      this.#view.clearInput();
    } catch (error) {
      console.error("Failed to add todo:", error.message);
    }
  }
 
  handleToggleTodo(id) {
    try {
      this.#model.toggleTodo(id);
    } catch (error) {
      console.error("Failed to toggle todo:", error.message);
    }
  }
 
  handleDeleteTodo(id) {
    try {
      this.#model.removeTodo(id);
    } catch (error) {
      console.error("Failed to delete todo:", error.message);
    }
  }
 
  handleFilterChange(filter) {
    this.#model.setFilter(filter);
  }
 
  handleClearCompleted() {
    this.#model.clearCompleted();
  }
}
 
// Bootstrap the application
const model = new TodoModel();
const view = new TodoView("#app");
const controller = new TodoController(model, view);

Model with Persistence

javascriptjavascript
class PersistentModel extends EventEmitter {
  #data = {};
  #storageKey;
  #storage;
  #dirty = false;
  #saveTimer = null;
 
  constructor(storageKey, storage = localStorage) {
    super();
    this.#storageKey = storageKey;
    this.#storage = storage;
    this.#load();
  }
 
  #load() {
    try {
      const saved = this.#storage.getItem(this.#storageKey);
      if (saved) {
        this.#data = JSON.parse(saved);
        this.emit("loaded", this.#data);
      }
    } catch (error) {
      console.error("Failed to load state:", error);
    }
  }
 
  #scheduleSave() {
    this.#dirty = true;
    if (this.#saveTimer) return;
 
    this.#saveTimer = setTimeout(() => {
      this.#saveTimer = null;
      if (this.#dirty) {
        try {
          this.#storage.setItem(this.#storageKey, JSON.stringify(this.#data));
          this.#dirty = false;
          this.emit("saved");
        } catch (error) {
          this.emit("saveError", error);
        }
      }
    }, 100);
  }
 
  get(key) {
    return key ? this.#data[key] : { ...this.#data };
  }
 
  set(key, value) {
    const old = this.#data[key];
    this.#data[key] = value;
    this.#scheduleSave();
    this.emit("change", { key, value, old });
    return this;
  }
 
  delete(key) {
    const old = this.#data[key];
    delete this.#data[key];
    this.#scheduleSave();
    this.emit("change", { key, value: undefined, old });
    return this;
  }
 
  clear() {
    this.#data = {};
    this.#storage.removeItem(this.#storageKey);
    this.emit("cleared");
  }
}
 
// Usage
const settings = new PersistentModel("app-settings");
settings.on("change", ({ key, value }) => {
  console.log(`Setting "${key}" changed to:`, value);
});
settings.set("theme", "dark");
settings.set("language", "en");
MVC LayerResponsibilityKnows AboutCommunication
ModelData, business logic, validationNothingEmits events
ViewDOM rendering, user input captureNothingCalls handler callbacks
ControllerCoordination, error handlingModel and ViewBridges both layers
Rune AI

Rune AI

Key Insights

  • Models encapsulate data and business logic, communicating only through events: Models never reference Views or Controllers, making them independently testable and reusable
  • Views handle DOM rendering and user input capture without business logic: Views expose bind methods for event delegation and display methods for rendering data
  • Controllers wire Models to Views and handle error boundaries: The Controller subscribes to Model events and calls View methods, keeping both layers decoupled
  • Persistent Models debounce saves and load state from storage automatically: LocalStorage or sessionStorage integration happens at the Model layer, transparent to Views and Controllers
  • Event-driven communication between MVC layers enables loose coupling: Models emit change events, Controllers listen and update Views, creating a clean unidirectional flow
RunePowered by Rune AI

Frequently Asked Questions

Why use MVC in vanilla JavaScript when frameworks exist?

Understanding MVC in vanilla JS builds the foundational knowledge that makes framework usage more effective. You learn exactly how data binding, event delegation, and state management work without framework abstractions. This knowledge transfers to React (component state), Vue (reactive data), and Angular (services and components), making you faster at debugging and architecting in any framework.

How does MVC relate to modern frontend patterns like MVVM and Flux?

MVC uses controllers to mediate between models and views. MVVM (Model-View-ViewModel) replaces the controller with a view-model that provides two-way data binding. Flux (used by Redux) replaces the controller with a unidirectional data flow: actions trigger dispatchers that update stores, which notify views. All patterns separate data from presentation; they differ in how updates flow between layers.

Should the View know about the Model?

In strict MVC, the View knows nothing about the Model. The Controller reads data from the Model and passes it to the View. The View only accepts plain data and render instructions. This keeps the View reusable and testable in isolation. Some MVC variants allow the View to observe the Model directly, but this increases coupling.

How do I handle multiple Models in MVC?

Create each Model independently with its own events. The Controller subscribes to all relevant Models and coordinates updates to the View. For shared state between Models, use a mediator Model or an event bus. Avoid direct Model-to-Model communication; let the Controller orchestrate multi-Model operations.

Conclusion

MVC architecture separates data management (Model), presentation (View), and coordination (Controller) into independent, testable layers. The Model emits events on state changes. The View captures user input and renders data. The Controller bridges both layers, handling errors and business logic flow. For building complete applications, see Building Vanilla JS Apps with MVC Architecture. For the observer pattern that powers MVC communication, review JavaScript Observer Pattern: Complete Guide.