Building Vanilla JS Apps with MVC Architecture
Learn to build vanilla JavaScript applications using MVC architecture. Covers project structure, routing, service layers, nested views, form handling with validation, state synchronization, and testing MVC components independently.
Building real applications with MVC requires more than just separating three layers. This guide covers routing, service layers, nested views, form validation, and testing strategies that transform the basic MVC pattern into a production-ready architecture.
For the MVC pattern foundations, see JavaScript MVC Architecture: Complete Guide.
Application Router
class Router {
#routes = new Map();
#currentRoute = null;
#notFoundHandler = null;
#beforeHooks = [];
addRoute(path, handler) {
// Convert path params to regex: /users/:id -> /users/([^/]+)
const paramNames = [];
const pattern = path.replace(/:([^/]+)/g, (_, name) => {
paramNames.push(name);
return "([^/]+)";
});
this.#routes.set(path, {
regex: new RegExp(`^${pattern}$`),
paramNames,
handler,
});
return this;
}
notFound(handler) {
this.#notFoundHandler = handler;
return this;
}
beforeEach(hook) {
this.#beforeHooks.push(hook);
return this;
}
async navigate(path, pushState = true) {
// Run before hooks
for (const hook of this.#beforeHooks) {
const allowed = await hook(path, this.#currentRoute);
if (!allowed) return false;
}
for (const [routePath, route] of this.#routes) {
const match = path.match(route.regex);
if (match) {
const params = {};
route.paramNames.forEach((name, i) => {
params[name] = match[i + 1];
});
this.#currentRoute = { path, routePath, params };
if (pushState) {
history.pushState({ path }, "", path);
}
await route.handler(params);
return true;
}
}
if (this.#notFoundHandler) {
this.#notFoundHandler(path);
}
return false;
}
start() {
window.addEventListener("popstate", (e) => {
if (e.state?.path) {
this.navigate(e.state.path, false);
}
});
// Handle link clicks
document.addEventListener("click", (e) => {
const link = e.target.closest("a[data-route]");
if (link) {
e.preventDefault();
this.navigate(link.getAttribute("href"));
}
});
// Navigate to current path
this.navigate(window.location.pathname, false);
}
getCurrentRoute() {
return this.#currentRoute;
}
}
// Usage
const router = new Router();
router
.beforeEach((to, from) => {
if (to.startsWith("/admin") && !isAuthenticated()) {
router.navigate("/login");
return false;
}
return true;
})
.addRoute("/", (params) => renderHome())
.addRoute("/todos", (params) => renderTodoList())
.addRoute("/todos/:id", (params) => renderTodoDetail(params.id))
.notFound((path) => renderNotFound(path))
.start();Service Layer
class BaseService {
#baseUrl;
#headers;
constructor(baseUrl, headers = {}) {
this.#baseUrl = baseUrl;
this.#headers = {
"Content-Type": "application/json",
...headers,
};
}
async #request(method, path, body) {
const url = `${this.#baseUrl}${path}`;
const options = {
method,
headers: { ...this.#headers },
};
if (body) options.body = JSON.stringify(body);
const response = await fetch(url, options);
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.json();
}
get(path) { return this.#request("GET", path); }
post(path, data) { return this.#request("POST", path, data); }
put(path, data) { return this.#request("PUT", path, data); }
delete(path) { return this.#request("DELETE", path); }
setHeader(key, value) {
this.#headers[key] = value;
}
}
class TodoService extends BaseService {
constructor() {
super("/api");
}
getAll() { return this.get("/todos"); }
getById(id) { return this.get(`/todos/${id}`); }
create(data) { return this.post("/todos", data); }
update(id, data) { return this.put(`/todos/${id}`, data); }
remove(id) { return this.delete(`/todos/${id}`); }
}
// Model uses the service
class TodoModel {
#todos = [];
#service;
#listeners = new Map();
constructor(service) {
this.#service = service;
}
on(event, handler) {
if (!this.#listeners.has(event)) this.#listeners.set(event, []);
this.#listeners.get(event).push(handler);
}
#emit(event, data) {
(this.#listeners.get(event) || []).forEach((h) => h(data));
}
async load() {
this.#emit("loading", true);
try {
this.#todos = await this.#service.getAll();
this.#emit("loaded", this.#todos);
} catch (error) {
this.#emit("error", error);
} finally {
this.#emit("loading", false);
}
}
async add(text) {
const todo = await this.#service.create({ text, completed: false });
this.#todos.push(todo);
this.#emit("changed", this.#todos);
return todo;
}
async toggle(id) {
const todo = this.#todos.find((t) => t.id === id);
if (!todo) throw new Error("Not found");
const updated = await this.#service.update(id, { completed: !todo.completed });
Object.assign(todo, updated);
this.#emit("changed", this.#todos);
}
getAll() { return [...this.#todos]; }
}Nested View Components
class Component {
#element;
#children = new Map();
#cleanups = [];
constructor(tag = "div") {
this.#element = document.createElement(tag);
}
getElement() {
return this.#element;
}
mount(container) {
container.appendChild(this.#element);
this.onMount?.();
return this;
}
unmount() {
this.onUnmount?.();
this.#cleanups.forEach((fn) => fn());
this.#cleanups = [];
for (const child of this.#children.values()) {
child.unmount();
}
this.#element.remove();
}
addChild(name, component) {
this.#children.set(name, component);
component.mount(this.#element);
return this;
}
removeChild(name) {
const child = this.#children.get(name);
if (child) {
child.unmount();
this.#children.delete(name);
}
}
addCleanup(fn) {
this.#cleanups.push(fn);
}
setHTML(html) {
this.#element.innerHTML = html;
}
addClass(...classes) {
this.#element.classList.add(...classes);
return this;
}
}
// Header component
class HeaderView extends Component {
constructor(title) {
super("header");
this.addClass("app-header");
this.setHTML(`
<h1>${title}</h1>
<nav>
<a href="/" data-route>Home</a>
<a href="/todos" data-route>Todos</a>
<a href="/settings" data-route>Settings</a>
</nav>
`);
}
}
// Todo list component with subcomponents
class TodoListView extends Component {
#itemViews = new Map();
constructor() {
super("div");
this.addClass("todo-list");
}
render(todos) {
const currentIds = new Set(todos.map((t) => t.id));
// Remove deleted items
for (const [id, view] of this.#itemViews) {
if (!currentIds.has(id)) {
view.unmount();
this.#itemViews.delete(id);
}
}
// Add/update items
for (const todo of todos) {
if (this.#itemViews.has(todo.id)) {
this.#itemViews.get(todo.id).update(todo);
} else {
const itemView = new TodoItemView(todo);
this.#itemViews.set(todo.id, itemView);
this.addChild(`todo-${todo.id}`, itemView);
}
}
}
}
class TodoItemView extends Component {
#todo;
#onToggle;
#onDelete;
constructor(todo) {
super("li");
this.#todo = todo;
this.addClass("todo-item");
this.#render();
}
#render() {
this.setHTML(`
<input type="checkbox" class="toggle" ${this.#todo.completed ? "checked" : ""}>
<span class="text">${this.#todo.text}</span>
<button class="delete" aria-label="Delete">x</button>
`);
this.getElement().classList.toggle("completed", this.#todo.completed);
}
update(todo) {
this.#todo = todo;
this.#render();
}
bindToggle(handler) {
this.#onToggle = handler;
this.getElement().querySelector(".toggle").addEventListener("change", () => {
handler(this.#todo.id);
});
}
bindDelete(handler) {
this.#onDelete = handler;
this.getElement().querySelector(".delete").addEventListener("click", () => {
handler(this.#todo.id);
});
}
}Form Handling with Validation
class FormView extends Component {
#fields = new Map();
#errors = new Map();
#validators = {};
constructor(config) {
super("form");
this.#validators = config.validators || {};
this.#renderForm(config.fields);
this.getElement().addEventListener("submit", (e) => {
e.preventDefault();
});
}
#renderForm(fields) {
let html = "";
for (const field of fields) {
html += `
<div class="form-group" data-field="${field.name}">
<label for="${field.name}">${field.label}</label>
${this.#renderInput(field)}
<span class="error-message"></span>
</div>
`;
}
html += `<button type="submit" class="submit-btn">Submit</button>`;
this.setHTML(html);
}
#renderInput(field) {
switch (field.type) {
case "textarea":
return `<textarea id="${field.name}" name="${field.name}" placeholder="${field.placeholder || ""}"></textarea>`;
case "select":
return `<select id="${field.name}" name="${field.name}">
${field.options.map((o) => `<option value="${o.value}">${o.label}</option>`).join("")}
</select>`;
default:
return `<input type="${field.type || "text"}" id="${field.name}" name="${field.name}" placeholder="${field.placeholder || ""}">`;
}
}
getData() {
const data = {};
const inputs = this.getElement().querySelectorAll("input, textarea, select");
inputs.forEach((input) => {
data[input.name] = input.type === "checkbox" ? input.checked : input.value;
});
return data;
}
validate() {
const data = this.getData();
const errors = {};
for (const [field, rules] of Object.entries(this.#validators)) {
for (const rule of rules) {
const error = rule(data[field], data);
if (error) {
errors[field] = error;
break;
}
}
}
this.#displayErrors(errors);
return { valid: Object.keys(errors).length === 0, errors, data };
}
#displayErrors(errors) {
const groups = this.getElement().querySelectorAll(".form-group");
groups.forEach((group) => {
const field = group.dataset.field;
const errorEl = group.querySelector(".error-message");
if (errors[field]) {
errorEl.textContent = errors[field];
group.classList.add("has-error");
} else {
errorEl.textContent = "";
group.classList.remove("has-error");
}
});
}
reset() {
this.getElement().reset();
this.#displayErrors({});
}
bindSubmit(handler) {
this.getElement().addEventListener("submit", (e) => {
e.preventDefault();
const result = this.validate();
if (result.valid) {
handler(result.data);
}
});
}
}
// Usage
const form = new FormView({
fields: [
{ name: "title", label: "Title", type: "text", placeholder: "Enter title" },
{ name: "description", label: "Description", type: "textarea" },
{ name: "priority", label: "Priority", type: "select", options: [
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
]},
],
validators: {
title: [
(v) => (!v ? "Title is required" : null),
(v) => (v && v.length < 3 ? "At least 3 characters" : null),
],
description: [
(v) => (!v ? "Description is required" : null),
],
},
});Application Bootstrap
class Application {
#router;
#services = {};
#models = {};
#currentView = null;
#rootElement;
constructor(rootSelector) {
this.#rootElement = document.querySelector(rootSelector);
this.#router = new Router();
this.#setup();
}
#setup() {
// Initialize services
this.#services.todo = new TodoService();
// Initialize models
this.#models.todo = new TodoModel(this.#services.todo);
// Configure routes
this.#router
.addRoute("/", () => this.#showHome())
.addRoute("/todos", () => this.#showTodos())
.addRoute("/todos/:id", (params) => this.#showTodoDetail(params.id))
.notFound(() => this.#showNotFound());
// Render shell
const header = new HeaderView("MVC App");
header.mount(this.#rootElement);
const main = document.createElement("main");
main.id = "content";
this.#rootElement.appendChild(main);
}
#swapView(view) {
if (this.#currentView) {
this.#currentView.unmount();
}
this.#currentView = view;
view.mount(document.querySelector("#content"));
}
async #showTodos() {
const view = new TodoListView();
const controller = {
toggle: (id) => this.#models.todo.toggle(id),
add: (text) => this.#models.todo.add(text),
};
this.#models.todo.on("changed", (todos) => view.render(todos));
this.#swapView(view);
await this.#models.todo.load();
}
#showHome() {
const view = new Component("div");
view.setHTML("<h2>Welcome to MVC App</h2>");
this.#swapView(view);
}
#showNotFound() {
const view = new Component("div");
view.setHTML("<h2>Page Not Found</h2>");
this.#swapView(view);
}
start() {
this.#router.start();
}
}
// Start the app
const app = new Application("#app");
app.start();| MVC Component | Testing Strategy | Mock Dependencies |
|---|---|---|
| Model | Unit test methods, verify events emitted | Service (API layer) |
| View | DOM assertions, event trigger verification | None (pure DOM) |
| Controller | Integration: verify Model/View interaction | Both Model and View |
| Router | Route matching, navigation hooks | History API |
| Service | HTTP request/response assertions | Fetch API |
Rune AI
Key Insights
- Routers map URL paths to controller actions with parameter extraction: Named params (
:id) are extracted from URLs and passed to route handlers for dynamic page rendering - Service layers abstract API communication from models: Models call service methods instead of fetch directly, making API changes isolated to one layer
- Nested view components support mounting, unmounting, and cleanup: A Component base class manages child components, DOM insertion, and event listener teardown
- Form views validate data before notifying the controller: The validate method runs all field rules and displays errors inline, only calling the submit handler when valid
- Application bootstrap wires services, models, routes, and views in one place: The composition root creates all dependencies and connects layers, keeping individual components independent
Frequently Asked Questions
How do I handle shared state between multiple MVC triads?
How do I implement undo/redo in MVC?
Should I create one controller per view or one controller per feature?
How do I handle loading and error states in MVC?
Conclusion
Production MVC applications require routing, service layers, nested views, form validation, and a clean bootstrap process. The router maps URLs to controller actions. Services abstract API communication. Nested views compose the UI from reusable components. Form views handle validation before submitting data. For the MVC foundations, see JavaScript MVC Architecture: Complete Guide. For state management patterns that extend MVC, review Vanilla JS State Management for Advanced Apps.
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.