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.

JavaScriptadvanced
17 min read

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

The router maps URL paths to handler functions and supports parameter extraction from dynamic segments like /users/:id. It converts those path patterns into regexes at registration time, so matching is fast during navigation. Before each route change, it runs beforeEach hooks where you can do things like check authentication and redirect if needed.

javascriptjavascript
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

The service layer sits between your models and the actual HTTP requests. A base service class wraps fetch with standard headers, JSON parsing, and error handling, so each specific service (like TodoService) only needs to define its endpoints. The model then calls service methods and emits events when data changes, keeping the API details completely out of your controllers and views.

javascriptjavascript
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

Views in a real MVC app are rarely flat. You need a parent component that can mount and unmount children, handle cleanup when a child is removed, and avoid leaking event listeners. This Component base class tracks its children by name and tears them all down recursively when the parent unmounts. The todo list example shows how this works in practice, with the list view managing individual item views and diffing against incoming data so only the changed items get updated.

javascriptjavascript
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

Forms need to collect data, validate it, show inline errors, and only notify the controller when everything checks out. This FormView builds the form HTML from a config object and runs field-level validators on submit. The controller never has to think about DOM manipulation or error messages; it just receives clean, validated data through the bindSubmit callback.

javascriptjavascript
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

The bootstrap is where you wire everything together. It creates services, models, and the router, then configures which routes show which views. This is the composition root of your app, the one place that knows about all the pieces. Individual components stay independent and testable because they receive their dependencies from here rather than importing them directly.

javascriptjavascript
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 ComponentTesting StrategyMock Dependencies
ModelUnit test methods, verify events emittedService (API layer)
ViewDOM assertions, event trigger verificationNone (pure DOM)
ControllerIntegration: verify Model/View interactionBoth Model and View
RouterRoute matching, navigation hooksHistory API
ServiceHTTP request/response assertionsFetch API
Rune AI

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
RunePowered by Rune AI

Frequently Asked Questions

How do I handle shared state between multiple MVC triads?

Use a shared event bus or a global store that both models subscribe to. When one model updates shared state, the event bus notifies other models. Each controller independently updates its view when its model changes. Avoid direct model-to-model references; always communicate through events.

How do I implement undo/redo in MVC?

Store state snapshots in the model whenever changes occur. Maintain an undo stack of previous states and a redo stack of undone states. The controller adds "undo" and "redo" button handlers that restore the model to the appropriate snapshot. The view updates automatically through the normal model-change event flow.

Should I create one controller per view or one controller per feature?

One controller per feature (which may manage multiple views). A "Todos" controller manages the list view, form view, and detail view for the todo feature. This keeps related logic together. Very large features can be split into sub-controllers, with a parent controller coordinating them.

How do I handle loading and error states in MVC?

The model emits "loading" and "error" events alongside data change events. The view provides `showLoading()`, `showError(message)`, and `showData(data)` methods. The controller subscribes to all three model events and calls the appropriate view method. This keeps the loading/error UI logic in the view and the state management in the model.

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.