Course outline

JavaScript / Advanced: scheduling and trustworthy interfaces / Lesson 18

API adapters and explicit UI architecture

Combine language contracts into a small maintainable interface architecture. Separate transport, parsing, domain rules, state transitions, rendering, and release evidence using synthetic adapters.

About 4 min readContent reviewed
On this page

Lesson goals

  • Validate transport and domain outcomes separately.
  • Represent UI state with explicit transitions.
  • Test adapters and workflows at meaningful boundaries.

Choose a few clear responsibilities

A small client can separate a transport adapter, a data decoder, domain calculations, state transitions, and DOM rendering without adopting a large framework. The adapter deals with HTTP and cancellation; the decoder checks external shapes; domain functions operate on validated values; the transition function decides state; the renderer presents it accessibly. Keep dependencies directional so domain rules do not require document or a real network. This organization is useful only if boundaries remain understandable. Avoid abstraction layers that merely rename one call while hiding which component owns errors, request identity, or cleanup.

Transport success is not domain validity

fetch normally fulfills for HTTP error responses such as 404 or 500, so inspect response.ok or an explicit status policy before decoding a success payload. JSON parsing can fail, and valid JSON can still have the wrong domain shape. The example injects a synthetic fetch-like function and validates a small course record without making a request. A real adapter should carry an owned signal and avoid leaking tokens into errors. Decide which failures can be retried safely; a lost response to a write is not equivalent to proof that the write never happened.

javascript A local adapter contract with no network
async function loadCourse(fetchLike) {
  const response = await fetchLike();
  if (!response.ok) throw new Error("course HTTP failure");
  const data = await response.json();
  if (typeof data.title !== "string" || !Number.isInteger(data.seats) || data.seats < 0) {
    throw new TypeError("invalid course shape");
  }
  return { title: data.title, seats: data.seats };
}
const course = await loadCourse(async () => ({
  ok: true, json: async () => ({ title: "DOM", seats: 2 })
}));
console.log(course.title, course.seats);
Expected output
DOM 2

The injected response supplies known data. HTTP acceptance, JSON decoding, and shape checks remain distinct steps.

Model states instead of conflicting flags

Independent flags such as loading, failed, and loaded can accidentally describe impossible combinations. A tagged state can represent idle, loading with request identity, ready with validated data, or error with a safe message. Transitions accept defined events and reject or ignore events that do not belong to the active request. Keep transition logic pure when practical so a test can inspect exact before/after state. Rendering then follows one coherent state rather than reconstructing meaning from scattered variables. Distinguish no results from a load failure, and preserve useful existing data only when the product explicitly allows stale display.

Ignore stale events at the state boundary

The example begins a request with identity two, receives an obsolete success for one, and then accepts success for two. The stale event leaves loading unchanged. A production transition function would also define failure, cancellation, retry, and disposal events and validate event payloads at the boundary. Avoid performing DOM updates inside this function, since that would mix decision and effect and make replay harder. The renderer can announce loading and error states accessibly, while the coordinating layer owns starting work and releasing listeners or abort controllers.

javascript Pure state decisions preserve request ownership
function transition(state, event) {
  if (event.type === "load") return { status: "loading", request: event.id };
  if (event.type === "success" && state.status === "loading" && state.request === event.id) {
    return { status: "ready", data: event.data };
  }
  return state;
}
let state = transition({ status: "idle" }, { type: "load", id: 2 });
state = transition(state, { type: "success", id: 1, data: "old" });
console.log(state.status);
state = transition(state, { type: "success", id: 2, data: "DOM" });
console.log(state.status, state.data);
Expected output
loading
ready DOM

The obsolete event is ignored without rendering. The current event produces one unambiguous ready state.

Verify the composed user outcome

Test pure rules with exact examples, adapter failures with controlled responses, transitions with event sequences, and one complete browser task with keyboard and persistent-state observations where relevant. Keep one real contract check in an authorized environment rather than treating a fake response as evidence of a real server. Document supported hosts, module loading, error meanings, cancellation ownership, and untested assumptions. Performance, security, and accessibility remain properties of the complete interaction, not badges earned by choosing a framework. A reviewable release argument states what was observed, what remains uncertain, and how the design makes future changes understandable.

Common errors and fixes

HTTP 500 is treated as successful data

Why it happens
A fulfilled fetch promise is mistaken for an accepted response.
How to fix it
Check status policy before success decoding.
Prevent it next time
Test HTTP errors, invalid JSON, and wrong shapes separately.

Several flags describe contradictory UI states

Why it happens
State transitions are scattered across callbacks.
How to fix it
Use explicit tagged states and owned transition events.
Prevent it next time
Test event sequences, including stale and rejected outcomes.

A fake adapter is treated as server evidence

Why it happens
Local simulation is confused with real contract integration.
How to fix it
Keep a controlled integration check and disclose untested assumptions.
Prevent it next time
Review adapter fixtures when the external contract changes.

Check your understanding

1. A response is valid JSON but seats is "two". Where should rejection occur?

Show worked solution

Reject in the boundary decoder before creating a domain record or ready state. A JSON parser verifies syntax, not semantic field types. Return or throw the agreed safe error, keep the UI in its defined failure state, and test that no enrollment action uses the invalid record.

2. Give a minimal evidence set for a new course search interface.

Show worked solution

Include pure filtering examples, HTTP and shape rejection cases, reversed-request state transitions, and one keyboard browser journey covering loading, empty results, correction, and current results. Record unsupported environments and external assumptions. This is a reasoned starting set, not an exhaustive proof of correctness.

Key takeaways

  • Separate transport, decoding, domain decisions, state, and rendering.
  • Architecture is useful when its ownership and evidence stay understandable.

Official references