[JavaScript Series 12] Midpoint Build: Integrating Forms, Storage, and Components

한국어 버전

Part 12 is a mid-series milestone project where we bring everything together. It pulls input validation, storage helpers, event control, and component structure onto one screen so you can confirm the flow before heading toward the capstone in part 20. Follow the order “core (storage ↔ events ↔ components) → optional (stats, activity log, BrowserMock)”.

Key terms

  1. Storage wrapper: helper pairs such as loadTasks and saveTasks that wrap localStorage access for reuse.
  2. CRUD: the canonical create/read/update/delete cycle for working with data.

Core ideas

  • Module structure: keep storage, the event bus, and shared utilities inside lib/, while components/ holds the form, list, stats, and log modules.
  • Event bus: use an EventTarget with emit/on helpers so events like task:add, task:updated, and filter:change have a single path.
  • Storage wrapper: loadTasks, saveTasks, and listenStorage standardize localStorage access and handle cross-tab sync.
  • Real-time feedback: link form validation, autosave, the stats panel, the activity log, and a throttled scroll progress bar.

Assembly order

  1. Data axis: define persistence and synchronization in lib/storage.js.
  2. Event axis: implement emit and on in lib/bus.js, then document the main event names as constants.
  3. Form → list → stats: the form emits events, the list mutates state, and the stats/log components subscribe to the results.
  4. UI feedback: attach elements like a progress bar, activity log, or toast so users always know where they are.

Starter snippet

// Minimal backbone sample
const tasks = [];
function addTask(title) {
  tasks.push({ title, done: false });
  console.log(tasks);
}
addTask("Homework");

➡️ As long as “update an array, let other functions read it” feels natural, splitting modules later becomes far simpler.

Building the Storage Module

Start by creating a storage module that groups read/write/sync logic.

// lib/storage.js
const STORAGE_KEY = "starter-dashboard";

export function loadTasks() {
  try {
    return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
  } catch {
    return [];
  }
}

export function saveTasks(tasks) {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
}

export function listenStorage(update) {
  window.addEventListener("storage", (event) => {
    if (event.key !== STORAGE_KEY || !event.newValue) return;
    update(JSON.parse(event.newValue));
  });
}

In one sentence: every data I/O action flows through lib/storage.js, so you never have to hunt for where persistence happens.

Connecting components with events

Next, wire an event bus so components use a single channel for signals.

// lib/bus.js
export const bus = new EventTarget();

export function emit(type, detail) {
  bus.dispatchEvent(new CustomEvent(type, { detail }));
}

export function on(type, handler) {
  bus.addEventListener(type, handler);
  return () => bus.removeEventListener(type, handler);
}

Think of emit("task:add", detail) as “shout the update” and on("task:add", handler) as “listen for it”.

Creating the Task Form Component

Create a form component that handles validation plus draft persistence.

// components/task-form.js

const constraints = {
  title: { required: true, minLength: 3 },
  owner: { required: true },
};

export function initTaskForm(root) {
  const form = root.querySelector("form");
  const feedback = root.querySelector(".form-feedback");

  const persistDraft = debounce(() => {
    const data = Object.fromEntries(new FormData(form));
    sessionStorage.setItem("task-draft", JSON.stringify(data));
  }, 300);

  function validateField(name, value) {
    const rules = constraints[name];
    if (!rules) return { valid: true };
    if (rules.required && !value.trim()) return { valid: false, message: "This field is required." };
    if (rules.minLength && value.length < rules.minLength)
      return { valid: false, message: `Enter at least ${rules.minLength} characters.` };
    return { valid: true };
  }

  form?.addEventListener("input", (event) => {
    const target = event.target;
    if (!(target instanceof HTMLInputElement)) return;
    const { valid, message } = validateField(target.name, target.value);
    target.classList.toggle("is-invalid", !valid);
    const hint = target.nextElementSibling;
    if (hint) hint.textContent = message ?? "";
    persistDraft();
  });

  form?.addEventListener("submit", (event) => {
    event.preventDefault();
    const data = Object.fromEntries(new FormData(form));
    const invalid = Object.entries(data).find(([name, value]) => !validateField(name, value).valid);
    if (invalid) {
      if (feedback) feedback.textContent = "Check the inputs again.";
      return;
    }
    emit("task:add", { task: { ...data, id: crypto.randomUUID(), done: false, createdAt: Date.now() } });
    form.reset();
    sessionStorage.removeItem("task-draft");
    if (feedback) feedback.textContent = "Task added.";
  });
}

Remember two steps: validate and autosave on every input, then emit task:add on submit.

Developing the Task List Component

The list component binds storage and the event bus to keep the UI current.

// components/task-list.js

export function initTaskList(root) {
  let tasks = loadTasks();
  let filter = "all";

  function render() {
    const filtered = filter === "all" ? tasks : tasks.filter((task) => (filter === "done" ? task.done : !task.done));
    root.querySelector(".task-list").innerHTML = filtered
      .map(
        (task) => `
        <li class="task ${task.done ? "task--done" : ""}">
          <label>
            <input type="checkbox" data-id="${task.id}" ${task.done ? "checked" : ""} />
            <span>${task.title}</span>
          </label>
          <button class="task-remove" data-id="${task.id}">Remove</button>
        </li>`,
      )
      .join("");
    root.querySelector(".task-count").textContent = `${tasks.filter((task) => !task.done).length} remaining`;
  }

  function update(newTasks) {
    tasks = newTasks;
    saveTasks(tasks);
    render();
    emit("task:updated", { tasks });
  }

  root.addEventListener("change", (event) => {
    const target = event.target;
    if (target instanceof HTMLInputElement && target.dataset.id) {
      update(tasks.map((task) => (task.id === target.dataset.id ? { ...task, done: target.checked } : task)));
    }
  });

  root.addEventListener("click", (event) => {
    const target = event.target;
    if (target instanceof HTMLButtonElement) {
      if (target.matches("[data-filter]")) {
        filter = target.dataset.filter ?? "all";
        render();
        emit("filter:change", { filter });
      }
      if (target.classList.contains("task-remove")) {
        update(tasks.filter((task) => task.id !== target.dataset.id));
      }
    }
  });

  on("task:add", ({ detail }) => {
    update([...tasks, detail.task]);
  });

  listenStorage((nextTasks) => {
    tasks = nextTasks;
    render();
  });

  render();
}

In short, the list keeps looping through “listen for events → update the array → persist → render again”.

Stats panel

The stats panel computes progress whenever it hears about task changes.

// components/stats-panel.js

export function initStatsPanel(root) {
  function render(detail) {
    const { tasks } = detail;
    const total = tasks.length || 1;
    const progress = Math.round((tasks.filter((task) => task.done).length / total) * 100);
    root.querySelector(".stat-progress").style.setProperty("--progress", progress + "%");
    root.querySelector(".stat-text").textContent = `Completion ${progress}%`;
  }

  on("task:updated", ({ detail }) => render(detail));
}

This is optional but shows how to consume events with minimal code.

Activity log

The log component records actions in chronological order.

// components/activity-log.js

export function initActivityLog(root) {
  const list = root.querySelector(".log-list");

  function push(message) {
    list.insertAdjacentHTML("afterbegin", `<li>${new Date().toLocaleTimeString()} · ${message}</li>`);
  }

  on("task:add", ({ detail }) => push(`Task added: ${detail.task.title}`));
  on("task:updated", ({ detail }) => {
    push(`Status updated (${detail.tasks.filter((task) => task.done).length}/${detail.tasks.length})`);
  });
}

Because a log is “append strings over time”, it is easy to add once the bus is ready.

Entry point glue code

Initialize everything in the entry point and throttle scroll feedback.

// index.js

initTaskForm(document.querySelector("#task-form"));
initTaskList(document.querySelector("#task-list"));
initStatsPanel(document.querySelector("#stats-panel"));
initActivityLog(document.querySelector("#activity-log"));

const updateProgress = throttle(() => {
  const progress = window.scrollY / (document.body.scrollHeight - window.innerHeight);
  document.querySelector(".page-progress").style.transform = `scaleX(${progress})`;
}, 100);

window.addEventListener("scroll", updateProgress, { passive: true });

index.js makes the initialization order obvious: call each init function per section, then append UX feedback such as the progress bar.

BrowserMock rehearsal

BrowserMock lets you replay the state → event → UI loop without a real browser so you can inspect the sequence quickly.


const { document, window } = mock(`
  <section id="task-form">
    <form>
      <input name="title" />
      <input name="owner" />
      <button>Add</button>
      <p class="form-feedback"></p>
    </form>
  </section>
  <section id="task-list">
    <ul class="task-list"></ul>
    <p class="task-count"></p>
  </section>
`);

initTaskForm(document.querySelector("#task-form"));
initTaskList(document.querySelector("#task-list"));

document.querySelector("input[name=title]").value = "Prep meeting";
document.querySelector("form").dispatchEvent(new window.Event("submit"));

console.log(document.querySelector(".task-count").textContent);

Without spinning up a browser you can confirm whether the assembly order and event bus behave correctly. BrowserMock sits between console logging and full UI tests: quick to run, yet visual enough to catch wiring mistakes.

Why it matters

  • A clear folder structure and event bus survive when you later add build tools such as Vite or Parcel.
  • Storage wrappers reduce duplication and ensure consistent data retention, tab sync, and error handling.
  • Feedback elements like the activity log or stats panel assure users that their input is saved.

Practice

  • Core follow-along: recreate the lib and components files so the CRUD flow works end to end.
  • Optional extension: enable storage sync, the event bus, and the throttled progress bar so all tabs share the same data.
  • Debugging: intentionally misspell an event name, trace where the flow stops, then refactor to constants.
  • Done when: you can replay “form validation → save → completion rate → log update” within a minute and see identical data in a new tab.

Wrap-up

Once this mid-series build makes the data → event → UI sequence obvious, the remaining chapters (13–19) simply add accessibility, routing, and performance. The capstone in part 20 ships the final build based on this flow, so capture a checklist of your current state now.

💬 댓글

이 글에 대한 의견을 남겨주세요