Skip to content

JavaScript for Interactive Interfaces

Step 7 of 45

Modules, Async Work, and Browser Events

Introduction

Real pages do not run in a straight line. A visitor clicks, types, submits a form; data arrives from a server a moment later; a request fails. This lesson covers the parts of JavaScript that deal with that unpredictability, within the JavaScript domain of the Meta Front-End Developer pathway (course 2): ES modules to organize code, the DOM and events that connect code to the page (the model React later hides behind components), promises and async/await for work that finishes later, fetch and JSON for talking to servers, and error handling for the three kinds of failure every request can have.

The practice scenario is original: load a mocked list of Harbor Workshops events and show useful feedback when loading fails. Running examples use a mocked fetch so that nothing depends on a real server.

Learning objectives

  1. Split code into ES modules using named and default exports and imports.
  2. Select elements, listen to events, read form values, and update the DOM safely.
  3. Explain the event loop well enough to predict the order of synchronous code, promise callbacks, and timers.
  4. Write and consume promises with then/catch and async/await.
  5. Use fetch, check response.ok, and parse JSON.
  6. Distinguish network errors, HTTP errors, and parsing errors and report each helpfully.
  7. Combine promises with Promise.all, allSettled, and race.

1. ES modules

A module is a file with its own scope. Names are private unless exported.

// money.js
export const TAX_RATE = 0.2;                  // named export
export function withTax(cents) {
  return Math.round(cents * (1 + TAX_RATE));
}
export default function formatEuros(cents) {  // default export (one per file)
  return `${(cents / 100).toFixed(2)} EUR`;
}
// main.js
import formatEuros, { withTax, TAX_RATE as rate } from "./money.js";
import * as money from "./money.js";           // namespace import

console.log(formatEuros(withTax(1000)));       // 12.00 EUR

Rules to remember:

Topic Rule
Named exports Imported with braces and the exact name (or renamed with as)
Default export One per file; imported without braces under any name
Paths Relative paths start with ./ or ../; bare names ("react") come from node_modules
Extensions Browsers require .js in paths; bundlers like Vite often let you omit it
In the browser <script type="module" src="main.js"></script>; modules are deferred and in strict mode
Live bindings Imports are read-only views of the exporter's variables

In the Vite-based React project you will use, every file is a module, and you will write import React style statements constantly. Prefer named exports for utilities (clearer, easier to refactor) and use default exports where a framework convention expects them, such as one component per file.

2. The DOM and events

The Document Object Model is the browser's live tree of objects for your page. JavaScript reads and changes it.

const list = document.querySelector("#events");        // first match or null
const buttons = document.querySelectorAll(".reserve"); // NodeList of all matches
const card = document.createElement("li");
card.textContent = "Pottery: 4 seats";
list.append(card);

Prefer textContent to insert plain text. innerHTML parses HTML, so inserting user-provided text through it opens the door to cross-site scripting (XSS): a name like <img src=x onerror=...> would run code. Use textContent, or build nodes with createElement.

Events

An event is something that happened: a click, a key press, a form submission. You attach a listener:

const form = document.querySelector("#booking");
form.addEventListener("submit", (event) => {
  event.preventDefault();                       // stop the full-page reload
  const data = new FormData(form);
  const seats = Number(data.get("seats"));
  if (!Number.isInteger(seats) || seats < 1) {
    document.querySelector("#error").textContent = "Enter a whole number of seats.";
    return;
  }
  console.log("Booking", seats);
});

Key ideas:

  • The listener receives an event object with target (the element that triggered it) and currentTarget (the element holding the listener).
  • Events bubble: a click on a button inside a card also triggers listeners on the card and its ancestors. You can stop that with event.stopPropagation(), but you usually should not.
  • Event delegation: attach one listener to a parent and inspect event.target, instead of one per child. This also covers items added later.
list.addEventListener("click", (e) => {
  const btn = e.target.closest("button[data-id]");
  if (btn) console.log("Reserve", btn.dataset.id);
});
  • input fires on every change in a field; change fires when the value is committed (blur or selection); submit is on the form.
  • A form field's .value is always a string, so convert it, as you learned in lesson 1.

React will replace most manual DOM code with declarative components and its own event props (onClick, onSubmit), but the underlying concepts (events, bubbling, preventDefault, string values) are identical. Learners who skip this section struggle later with forms.

3. The event loop in one page

JavaScript runs one piece of code at a time on a single thread. Slow operations (timers, network, file access) are handed to the browser, which later queues a callback. The event loop takes finished work from queues when the call stack is empty:

  1. Run the current synchronous code to completion.
  2. Run all pending microtasks (promise callbacks, await continuations).
  3. Run the next macrotask (a timer, a click handler).
  4. Repeat.
console.log("A");
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("microtask"));
console.log("B");
// A, B, microtask, timeout

Even a zero-delay timer runs after the promise callback because microtasks are drained first. A long synchronous loop blocks everything, including clicks and rendering, which is why heavy work must not sit in a handler.

4. Promises

A promise is an object representing a value that is not available yet. It is in one of three states: pending, fulfilled (with a value), or rejected (with a reason). Once settled it never changes.

fetch("/api/events")
  .then((response) => response.json())  // returns another promise
  .then((events) => console.log(events.length))
  .catch((error) => console.error(error)) // any rejection above lands here
  .finally(() => console.log("done"));    // always runs

Each then returns a new promise, which allows chaining. A value returned from a then callback becomes the next input; a thrown error skips to the next catch. Forgetting to return a promise inside then breaks the chain, because the next step would not wait.

async / await

async/await is syntax for working with promises in a top-down style.

async function loadEvents() {
  const response = await fetch("/api/events");
  const events = await response.json();
  return events;
}
  • An async function always returns a promise, even if you return 1.
  • await pauses only that function until the promise settles; other code keeps running.
  • await is allowed inside async functions and at the top level of modules.
  • Errors become exceptions, so you handle them with try/catch.

A classic mistake: array.map(async (n) => n * 2) returns an array of promises, not numbers. Wrap it in await Promise.all(...).

Combining promises

Helper Resolves when Rejects when
Promise.all([...]) All fulfill, with an array of results Any rejects (first rejection)
Promise.allSettled([...]) All settle, with {status, value/reason} items Never
Promise.race([...]) The first settles The first settles with rejection
Promise.any([...]) The first fulfills All reject

Use all when every result is required, allSettled when partial results are acceptable (for example a dashboard with several independent widgets), and race for timeouts.

Run independent requests in parallel rather than awaiting each in turn:

const [events, hosts] = await Promise.all([getEvents(), getHosts()]); // parallel

Awaiting them one after the other would take the sum of both times instead of the longer of the two.

5. fetch and JSON

fetch(url, options) starts an HTTP request and returns a promise for a Response.

const response = await fetch("/api/bookings", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ eventId: 1, seats: 2 }),
});

Crucial behavior: fetch rejects only when the request could not be completed (offline, DNS failure, blocked by CORS). A response with status 404 or 500 is a fulfilled promise. You must check response.ok (true for statuses 200 to 299) yourself.

JSON (JavaScript Object Notation) is the standard text format for data exchange:

JSON.stringify({ a: 1, d: new Date(0), u: undefined, f() {}, n: null });
// '{"a":1,"d":"1970-01-01T00:00:00.000Z","n":null}'
JSON.parse('{"a":[1,2]}').a; // [1, 2]

Notes: keys and strings need double quotes; undefined and functions are dropped by stringify; dates become strings and are not restored by parse; JSON.parse throws a SyntaxError on invalid text. response.json() does the parse for you and rejects the same way if the body is not valid JSON, for example when a server returns an HTML error page.

6. Three kinds of failure

Kind What it looks like How you detect it Useful message
Network fetch rejects with TypeError: Failed to fetch catch "Cannot reach the server. Check your connection and try again."
HTTP Promise fulfills, response.ok is false, status 4xx/5xx if (!response.ok) "The server could not load events (error 500). Try again later."
Parsing response.json() rejects with SyntaxError catch "The server sent an unexpected response."

A robust loader treats all three:

async function load(url) {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    return { data: await response.json(), error: null };
  } catch (error) {
    return { data: [], error: `${error.name}: ${error.message}` };
  }
}

Returning { data, error } instead of throwing forces the caller to handle both outcomes, and it maps neatly onto the loading, error, and success states of a React component. Never show raw error text to visitors in production; log details for developers and display a friendly message.

Worked example: show events or an error

Task. The page has <ul id="events"> and <p id="status" role="status">. Load the event list from /api/events, render each title with its seats, and show a useful message when loading fails. Seats of 0 must read "Sold out".

Step 1: write a loader that returns { data, error } as above.

Step 2: render with safe DOM calls:

function render(events) {
  const list = document.querySelector("#events");
  list.replaceChildren(); // clear
  for (const e of events) {
    const li = document.createElement("li");
    li.textContent = `${e.title}: ${e.seats === 0 ? "Sold out" : `${e.seats} seats`}`;
    list.append(li);
  }
}

Step 3: orchestrate, showing a loading message first:

async function init() {
  const status = document.querySelector("#status");
  status.textContent = "Loading events...";
  const { data, error } = await load("/api/events");
  if (error) {
    status.textContent = "We could not load the events. Please try again.";
    console.error(error);
    return;
  }
  status.textContent = data.length === 0 ? "No events yet." : "";
  render(data);
}
init();

Trace with the mocked server. With a good response the status clears and the list shows Pottery: 4 seats and Weaving: Sold out. With HTTP 500 the loader returns Error: HTTP 500 and the visitor sees the friendly message. With a dropped connection the error is TypeError: Failed to fetch. With an HTML error page, response.json() throws SyntaxError, caught by the same catch.

Why other designs are worse. Checking only catch would miss the 500 case, because fetch fulfills. Using innerHTML with e.title would risk script injection if titles ever come from users. Awaiting without try/catch would leave an unhandled rejection and a page stuck on "Loading events...". Note also that the check for an empty list is separate from the error check: "no events" is a successful result, not a failure.

7. Pitfalls and exam tips

  • Pitfall: assuming fetch rejects on 404/500. Tip: the correct check is response.ok.
  • Pitfall: forgetting await before response.json(), then using a promise as data.
  • Pitfall: using await outside an async function in a non-module script (syntax error).
  • Pitfall: forEach(async ...) does not wait; use for...of with await or Promise.all(map(...)).
  • Pitfall: sequential awaits for independent requests.
  • Pitfall: forgetting event.preventDefault() on form submit, which reloads the page.
  • Pitfall: reading input.value as a number.
  • Pitfall: missing return inside a then callback.
  • Tip: predicting output order: synchronous code first, then promise callbacks, then timers.
  • Tip: if an option says "wrap the call in try/catch" and another says "check response.ok", real code often needs both: one for failed requests, one for failed responses.

Mini case study: the stuck spinner

Situation. The Harbor events page shows "Loading..." and never changes when the API returns a 503 error page. The code:

async function init() {
  const status = document.querySelector("#status");
  status.textContent = "Loading...";
  const response = await fetch("/api/events");
  const events = await response.json();
  status.textContent = `${events.length} events`;
}
init();

Questions. What exactly happens, in what order, and how do you fix it?

Commented solution.

  1. fetch fulfills because the server answered, with status 503 and an HTML body.
  2. response.json() tries to parse HTML and rejects with SyntaxError.
  3. There is no try/catch, so init() returns a rejected promise nobody handles. The last line never runs and the text stays at "Loading...". The console shows "Uncaught (in promise) SyntaxError".

Fix: check response.ok first, handle exceptions, and always leave the loading state:

async function init() {
  const status = document.querySelector("#status");
  status.textContent = "Loading...";
  try {
    const response = await fetch("/api/events");
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const events = await response.json();
    status.textContent = `${events.length} events`;
  } catch (error) {
    console.error(error);
    status.textContent = "Events are unavailable right now. Please try again later.";
  }
}
init();

Checking response.ok gives the more precise cause (503) in the log before the parse is even attempted.

Key terms

  • Module: a file with its own scope and explicit imports/exports.
  • DOM: the browser's object tree of the page.
  • Event bubbling: events travel up through ancestors.
  • Event delegation: one parent listener handles many children.
  • Event loop: scheduler that runs queued callbacks when the stack is empty.
  • Microtask: a queued promise continuation, run before timers.
  • Promise: an object for a future value (pending, fulfilled, rejected).
  • JSON: text format for structured data.
  • CORS: browser rule limiting cross-origin requests.

Summary checklist

  • I can write named and default exports and import them.
  • I select elements, attach listeners, and call preventDefault on submit.
  • I insert text with textContent, not innerHTML.
  • I can predict the order of synchronous code, promises, and timers.
  • I write async/await with try/catch.
  • I check response.ok because fetch does not reject on HTTP errors.
  • I can tell network, HTTP, and parsing failures apart and message each.
  • I run independent requests with Promise.all and know when to use allSettled.
  • I know what JSON.stringify drops.

Official sources