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.
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
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
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
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
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 Layer | Responsibility | Knows About | Communication |
|---|---|---|---|
| Model | Data, business logic, validation | Nothing | Emits events |
| View | DOM rendering, user input capture | Nothing | Calls handler callbacks |
| Controller | Coordination, error handling | Model and View | Bridges both layers |
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
Frequently Asked Questions
Why use MVC in vanilla JavaScript when frameworks exist?
How does MVC relate to modern frontend patterns like MVVM and Flux?
Should the View know about the Model?
How do I handle multiple Models in MVC?
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.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.