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.
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.
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);
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.
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);
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.