Frontend Architecture Basics

Course: JavaScript Advanced

Lesson 19: Frontend Architecture Basics

Level

Advanced

Estimated Reading Time

10–15 minutes

Goal

By the end of this lesson, the learner will be able to:

  • Explain the separation of concerns between state management, business logic, and view rendering
  • Organize frontend code into layers that can change independently
  • Apply component thinking to break UI into reusable, single-responsibility units

Frontend Development Context

As frontend applications grow beyond a few pages and a handful of scripts, unstructured code becomes a serious problem. Logic for fetching data, transforming it, validating user input, managing application state, and updating the DOM all mixed together in the same file becomes unmaintainable. Changes in one area unexpectedly break another. Adding features requires understanding everything at once.

Frontend architecture is the practice of deliberately organizing code into layers with clear responsibilities and boundaries. Even without a framework, the same architectural principles that React, Vue, and Angular are built on can be applied to vanilla JavaScript: separate the data from the logic, the logic from the view, and make each piece independently replaceable. This lesson gives you the vocabulary and the patterns to think about frontend code as a system, not just a collection of scripts.

Explanation

Separation of concerns is the principle that different aspects of a program — data fetching, business logic, UI rendering — should be handled by different, independent pieces of code. When these concerns are mixed, a change to how data is fetched requires touching the same code as the rendering logic. When they are separated, each can evolve independently.

The three layers of a frontend application are: State (what data does the app hold?), Logic (how does the data transform and how do rules apply?), and View (how is the current state presented to the user?). These map directly to how React, Vuex, and Angular organize their code — the pattern is consistent even across different frameworks.

State management means having a single, authoritative source of truth for application data. Instead of reading the DOM to find the current value of something, the state layer holds it. Instead of scattered global variables, state lives in one place with a defined shape. When state changes, the view re-renders to reflect it. This one-way flow — state to view — is more predictable than two-way DOM-to-data binding.

Business logic is the rules and transformations that turn raw data into meaningful application state. Calculating a cart total, validating a login form, determining which notifications are unread — these are business logic. They do not depend on how the UI looks or how the data is fetched. Kept separate, they are pure functions or isolated modules that are testable without a browser.

Component thinking means breaking the UI into self-contained, reusable pieces, each responsible for rendering one concept. A SearchBar component knows how to display a search input and emit search events. A ProductCard component knows how to display a product. Neither cares how the other works. This granularity makes it possible to change, test, and reuse individual pieces without affecting the rest of the UI.

Code Example

This example implements a small "task manager" feature with explicitly separated state, logic, and view layers — no framework required.

// ============================================================
// STATE LAYER — single source of truth
// ============================================================
const state = {
  tasks: [],
  filter: "all", // "all" | "active" | "completed"
  nextId: 1,
};

function getState() {
  return { ...state, tasks: [...state.tasks] }; // return copy — no external mutation
}

// ============================================================
// LOGIC LAYER — pure functions, no DOM access
// ============================================================
function addTask(currentState, title) {
  if (!title.trim()) throw new Error("Task title cannot be empty");
  return {
    ...currentState,
    tasks: [
      ...currentState.tasks,
      { id: currentState.nextId, title: title.trim(), completed: false },
    ],
    nextId: currentState.nextId + 1,
  };
}

function toggleTask(currentState, taskId) {
  return {
    ...currentState,
    tasks: currentState.tasks.map(task =>
      task.id === taskId ? { ...task, completed: !task.completed } : task
    ),
  };
}

function getFilteredTasks(tasks, filter) {
  switch (filter) {
    case "active":    return tasks.filter(t => !t.completed);
    case "completed": return tasks.filter(t => t.completed);
    default:          return tasks;
  }
}

function getTaskSummary(tasks) {
  const total = tasks.length;
  const completed = tasks.filter(t => t.completed).length;
  return { total, completed, remaining: total - completed };
}

// ============================================================
// VIEW LAYER — reads state, writes DOM, emits events
// ============================================================
function renderTaskList(container, tasks) {
  container.innerHTML = tasks.length === 0
    ? "<p>No tasks to show.</p>"
    : tasks.map(task => `
        <div class="task ${task.completed ? "done" : ""}">
          <input type="checkbox" data-id="${task.id}" ${task.completed ? "checked" : ""}>
          <span>${task.title}</span>
        </div>
      `).join("");
}

function renderSummary(element, summary) {
  element.textContent =
    `${summary.completed}/${summary.total} completed (${summary.remaining} remaining)`;
}

// ============================================================
// CONTROLLER — wires state + logic + view together
// ============================================================
function createTaskManager(rootElement) {
  const listEl    = rootElement.querySelector(".task-list");
  const summaryEl = rootElement.querySelector(".summary");
  const inputEl   = rootElement.querySelector(".task-input");
  const addBtn    = rootElement.querySelector(".add-btn");

  function render() {
    const current = getState();
    const visible  = getFilteredTasks(current.tasks, current.filter);
    const summary  = getTaskSummary(current.tasks);
    renderTaskList(listEl, visible);
    renderSummary(summaryEl, summary);
  }

  addBtn.addEventListener("click", () => {
    try {
      const newState = addTask(getState(), inputEl.value);
      Object.assign(state, newState); // update the authoritative state
      inputEl.value = "";
      render();
    } catch (err) {
      console.warn("Add task failed:", err.message);
    }
  });

  listEl.addEventListener("change", (e) => {
    const id = Number(e.target.dataset.id);
    if (id) {
      Object.assign(state, toggleTask(getState(), id));
      render();
    }
  });

  render(); // initial render
}

Code Explanation

The state layer is a single object with a defined shape: tasks, filter, and nextId. getState() returns a shallow copy so no external code can accidentally mutate the state object directly. This is the source of truth — there is no other place in the application that decides what the current tasks are.

The logic layer consists entirely of pure functions. addTask, toggleTask, getFilteredTasks, and getTaskSummary all take state as input and return new state or derived values as output. None of them touch the DOM. Because they are pure, they can be tested by calling them with controlled inputs and asserting on the returned objects — no browser needed.

The view layer — renderTaskList and renderSummary — knows only about DOM manipulation. They receive data as arguments and produce HTML. They have no knowledge of how that data was fetched or transformed. Changing the UI representation is isolated to this layer.

The controller (createTaskManager) is the thin layer that wires everything together. It attaches event listeners that call logic functions, updates state with Object.assign, and calls render() after each state change. render() reads the current state, computes derived values through the logic layer, and calls the view functions. This one-way flow — event → logic → state → render — is the same pattern used by React, Vuex, and Redux, just expressed without a framework.

Pattern Highlights

Positive Pattern: Keep logic functions pure — no DOM access, no fetch, no console.log — so they can be tested in isolation and moved between projects without carrying browser dependencies.

⚠️ Neutral Note: Separation of concerns adds upfront structure. For a 50-line script, it may be unnecessary overhead. Apply architectural layers deliberately when the application has enough complexity that mixed concerns start causing confusion or making changes risky.

Negative Pattern: Do not read the DOM to find the current value of application state — reading from input.value or element.textContent to determine "what the current state is" couples your logic to the DOM and makes the state implicit, unobservable, and untestable.

Common Mistakes

Mistake 1: Treating the DOM as the source of truth

Reading values from DOM elements as if they represent application state means logic depends on the DOM being correct.

function calculateTotal() {
  const qty = parseInt(document.querySelector("#qty").value); // state in DOM
  const price = parseFloat(document.querySelector("#price").textContent);
  return qty * price; // logic depends on DOM structure
}

Store quantity and price in a state object. Read the state to calculate totals, and render state to DOM — never the other way around.

Mistake 2: Mixing API calls with rendering logic

Fetching data and updating the DOM in the same function couples them, making it impossible to reuse or test either.

async function loadAndRenderUsers() {
  const users = await fetch("/api/users").then(r => r.json());
  document.querySelector("#list").innerHTML =
    users.map(u => `<li>${u.name}</li>`).join(""); // fetch + render mixed
}

Split into fetchUsers() (returns data) and renderUserList(container, users) (renders data). The controller calls both. Each is independently testable.

Mistake 3: Creating monolithic components that do everything

A single component that handles routing, data fetching, validation, and rendering is a maintenance nightmare.

class App {
  handleSearch()    { /* fetch + validate + render */ }
  handleCheckout()  { /* validate + charge + redirect + log */ }
  handleProfile()   { /* fetch + render + edit + save */ }
  // 400 lines of mixed concerns
}

Apply SRP at the component level: separate search, checkout, and profile into independent components. Each component owns one feature area. Logic shared between them lives in standalone utility modules.

Quick Recap

  • Separate state (what data the app holds), logic (how data transforms and what rules apply), and view (how current state is rendered) into distinct layers that can change independently.
  • State should be the single source of truth — no logic should read the DOM to determine what the current application state is.
  • Logic functions should be pure — no DOM access, no side effects — so they are testable, reusable, and portable across different rendering contexts.
  • Component thinking breaks the UI into self-contained units, each with a single responsibility, wired together by a thin controller or orchestrator.

Ready to test your understanding? Take the quiz to reinforce what you learned.

© 2026 Ant Skillsv.26.07.07-23:01