Controllers

The Course
•
5 min read

A controller connects the pure pieces

Our controller receives search and render dependencies. It owns state, a generation counter, the active AbortController, and a disposed flag. Its load method cleans the query, invalidates older work, starts a request, decodes the returned books, and sends a transition only if the attempt is still current.

A small send(action) helper updates state with the reducer and calls render. Keeping those together avoids a state update that forgets to refresh the interface. The optional initial saved array must already satisfy the book contract; decode restored storage before passing it. The renderer must treat state as read-only; getState returns the current snapshot by convention, not a frozen deep copy.

There are two guards after asynchronous work: disposed prevents updating a departed view, and mine !== generation prevents an older attempt from replacing a newer one. The error path needs the same checks. Cancel invalidates the generation before aborting, so an abort rejection is stale and does not become a service error on the screen.

Read the smaller controller below before opening the full project. It deliberately omits cancellation and schema validation to isolate the ownership rule; the downloadable controller includes them. The dependencies make it possible to test this flow with no DOM and no timer sleeps.

Predict and trace

Write the output before running. Follow the values through each call; if your prediction differs, identify the first step that surprised you.

Predict, then run JavaScript
async function main() {
  function createLoader(search, render) {
    let generation = 0;

    return async function load(query) {
      const mine = ++generation;

      render("loading");

      try {
        const value = await search(query);

        if (mine === generation) {
          render(value);
        }
      } catch (error) {
        if (mine === generation) {
          render(error.message);
        }
      }
    };
  }

  const messages = [];
  const load = createLoader(
    async () => "ready",
    text => messages.push(text)
  );

  await load("web");
  console.log(messages.join(", "));
}

main();
Check your prediction
loading, ready

The search double fulfills predictably. The renderer records transitions instead of touching a page.

Run the actual controller without a webpage

The console lab below combines the same controller.js and model.js used by the final app inside an async main entry point. The short entry code at the top supplies a controlled search function and a renderer that prints status. The first load returns a valid book; the second returns a wrong-type title. Predict the sequence, then follow each status to its reducer action.

The real controller without a webpage JavaScript
async function main() { // A console entry point for the actual controller, independent of the DOM. let response = [{ id: " js ", title: " Notes " }]; const app = createController({ search: async () => response, render(state) { console.log(state.status); } }); await app.load("web"); console.log(app.getState().results[0].title); response = [{ id: "bad", title: 42 }]; await app.load("web"); console.log(app.getState().error); app.dispose(); function decodeBooks(value) { if (!Array.isArray(value)) { throw new Error("Expected a list of books"); } const ids = new Set(); return value.map(item => { if ( item === null || typeof item !== "object" || Array.isArray(item) || typeof item.id !== "string" || item.id.trim() === "" || typeof item.title !== "string" || item.title.trim() === "" ) { throw new Error("Each book needs an id and title"); } const id = item.id.trim(); if (ids.has(id)) { throw new Error("Duplicate book id"); } ids.add(id); return { id, title: item.title.trim() }; }); } function toggleSaved(saved, book) { if (saved.some(item => item.id === book.id)) { return saved.filter(item => item.id !== book.id); } return [...saved, { ...book }]; } function initialState(saved = []) { return { status: "idle", results: [], saved, error: "" }; } function reduce(state, action) { switch (action.type) { case "loading": return { ...state, status: "loading", results: [], error: "" }; case "loaded": return { ...state, status: "ready", results: action.books, error: "" }; case "failed": return { ...state, status: "error", results: [], error: action.message }; case "cancelled": return { ...state, status: "idle", results: [], error: "" }; case "toggle": return { ...state, saved: toggleSaved(state.saved, action.book) }; default: throw new Error("Unknown action"); } } // search(query, { signal }) returns a promise for untrusted book data. function createController({ search, render, saved = [] }) { let state = initialState(saved); let generation = 0; let active = null; let disposed = false; const send = action => { state = reduce(state, action); render(state); }; return { getState() { return state; }, async load(text) { if (disposed) { return; } const query = text.trim(); const mine = ++generation; active?.abort(); active = null; if (!query) { send({ type: "failed", message: "Enter a search topic." }); return; } active = new AbortController(); const request = active; send({ type: "loading" }); try { const books = decodeBooks( await search(query, { signal: request.signal }) ); if (disposed || mine !== generation) { return; } send({ type: "loaded", books }); } catch (error) { if (disposed || mine !== generation) { return; } send({ type: "failed", message: error instanceof Error ? error.message : "Search failed" }); } finally { if (mine === generation) { active = null; } } }, cancel() { if (disposed) { return; } generation += 1; active?.abort(); active = null; send({ type: "cancelled" }); }, toggle(book) { if (!disposed) { send({ type: "toggle", book }); } }, dispose() { disposed = true; generation += 1; active?.abort(); active = null; } }; } } main();
Check your prediction
loading
ready
Notes
loading
error
Each book needs an id and title

Only valid, trimmed books reach ready. The invalid response goes through the controller’s guarded catch into failed, rather than being rendered as a book. No DOM or live request is involved.

This single-program lab puts its entry code first, followed by the function definitions. Function declarations using function can be called earlier in the same scope; this is called hoisting. You do not have to organize your own code this way. The lab preserves the short, editable entry at the top while keeping the actual core functions available below it.

The console lab combines files because this editor’s file-tab mode does not support top-level await. The synchronous module example on Day 2 is unaffected; the downloaded project uses ordinary ES modules and runs controller-demo.js with Node. You do not need to install anything to practise here.

On your first pass, trace one successful load from the entry code, then trace the invalid response. You do not need to transcribe the combined definitions: the goal is to recognize where each earlier lesson participates.

Read the program in this order:

  1. In the entry code at the top, find the injected search and render dependencies.
  2. In createController below it (from controller.js), follow load from the loading action through search, decoding, and the current-generation check. async load(text) { ... } is an async method stored under load. active?.abort() calls abort only when an active controller exists.
  3. In the model functions (from model.js), find which fields each action changes and which saved fields it preserves.
  4. Return to the entry code, change the second title from 42 to a string, and predict which error output disappears. Run again.

This is the final app’s data flow before adding HTML. Keep the smaller sketch above as a way to isolate ownership, not as a replacement missing the full controller’s guards. In the downloaded project, node controller-demo.js runs this same console entry point.

Build it yourself

Exercise

Controllers: practise

Write an async attempt(load, report) that reports loading, then ready on success or error on failure. Pass a failing load and record the two statuses. This small function does not handle overlapping attempts. Treat this as a short recap; the complete walkthrough above is the bridge to the complete controller, not this stripped-down helper.

Your working space JavaScript
// Await the operation inside try/catch.
Hint 1: the idea

A fulfilled empty result is still success; rejection chooses error.

Hint 2: a first step

Keep the error report inside catch rather than in finally.

Show one solution
async function main() {
  async function attempt(load, report) {
    report("loading");

    try {
      await load();
      report("ready");
    } catch {
      report("error");
    }
  }

  const statuses = [];

  await attempt(
    async () => {
      throw new Error("offline");
    },
    status => statuses.push(status)
  );
  console.log(statuses.join(", "));
}

main();
loading, error

Try a loader returning []. Then explain exactly where generation checks must be added before reusing this function for overlapping searches.

Test a changed requirement

Try a loader returning []. Then explain exactly where generation checks must be added before reusing this function for overlapping searches.

Explain before moving on

Why must cancel invalidate before abort? What would an unconditional render in finally break?

Was this article helpful?

Your response is saved on this device.