WEBDEVMASTERS
Behavior with evidence

JavaScript that makes state visible

Model events, state transitions, async ownership, cancellation, and failure explicitly so the interface remains understandable under slow or conflicting input.

JavaScript events and state represented as connected debugging nodes
Reliable behavior begins when possible states and transitions are named before handlers are written.

Start with the state model

An interface is easier to reason about when its state is smaller than its DOM. A search result region may be idle, loading, successful with results, successful but empty, or failed. Those are product states, not implementation details. If code only models “has data” and “does not have data,” loading and error behavior will emerge through accidental combinations of classes and stale nodes.

Write the transitions first: a query starts loading; a newer query supersedes the previous request; success stores results; an empty response stores an empty collection; failure stores an error that can be retried. Rendering then becomes a projection of the current state instead of a sequence of unrelated DOM mutations.

const state = {
  status: "idle",
  query: "",
  results: [],
  error: null
};

function renderSearch(root, state) {
  root.dataset.status = state.status;
  root.querySelector("[data-status]").textContent =
    state.status === "loading" ? "Searching…" :
    state.status === "error" ? state.error :
    state.results.length === 0 ? "No matching guides." :
    `${state.results.length} guides found.`;
}

The status message should be perceivable when it changes, which connects this model to the accessible JavaScript guidance. Keep authoritative state in one place; derived values such as a result count can be computed during rendering rather than synchronized separately.

Handle the event that expresses intent

Use click for button activation rather than pointer-specific events when the action should work with mouse, touch, keyboard, and assistive technology. Use submit on the form rather than click on one submit button, because forms can be submitted from the keyboard or through browser behavior. Use event delegation when many stable child elements share one action, but identify the intended target with closest() and guard the container boundary.

const list = document.querySelector("[data-guide-list]");

list.addEventListener("click", (event) => {
  const button = event.target.closest("button[data-save]");
  if (!button || !list.contains(button)) return;
  saveGuide(button.dataset.save);
});

Separate intent from effect. The handler can read the relevant identifier and call a function that owns the state transition. This makes the behavior callable from a test and prevents a long event listener from mixing DOM discovery, network requests, persistence, analytics, and presentation.

Give async work an owner and an expiry

Async results can arrive in a different order from the requests that created them. In type-ahead search, a slow response for “ca” can overwrite a fast response for “cascade.” Use an AbortController to cancel the request that no longer belongs to the current interaction, and retain a monotonically increasing sequence as a guard for operations that cannot be canceled.

let activeController;
let requestSequence = 0;

async function searchGuides(query) {
  activeController?.abort();
  activeController = new AbortController();
  const sequence = ++requestSequence;

  setState({ status: "loading", query, results: [], error: null });
  try {
    const response = await fetch("/api/guides?q=" + encodeURIComponent(query), {
      signal: activeController.signal
    });
    if (!response.ok) throw new Error("Search returned HTTP " + response.status);
    const results = await response.json();
    if (sequence !== requestSequence) return;
    setState({ status: "success", query, results, error: null });
  } catch (error) {
    if (error.name === "AbortError") return;
    if (sequence !== requestSequence) return;
    setState({ status: "error", query, results: [], error: "Search failed. Try again." });
  }
}

Fetch does not turn every HTTP error into a rejected promise, so check response.ok. Cancellation is not an error to announce when it merely represents a superseded query. A genuine failure should leave the interface in a recoverable state with a retry path, not an indefinite spinner.

EntityAttributeExplicit valueEvidence
RequestOwnerCurrent user intentController aborts superseded work
ResponseValiditySequence equals current sequenceStale result cannot commit
UI stateStatusIdle, loading, success, errorOne render path for every state
ErrorRecoveryClear message plus retryUser can continue without reload

Design loading, empty, error, and partial states

A successful response with zero results is not an error. A cached result displayed while a refresh fails is not the same as an empty screen. Decide whether old data remains visible, whether actions are disabled during a mutation, and how a retry avoids duplicate submissions. For writes, attach an idempotency key or design the server operation to tolerate repeated requests when network outcome is uncertain.

Do not disable the whole interface because one region is loading. Keep stable content available, mark the changing region with an appropriate busy state, and move focus only when the user’s action logically opens a new context, such as a dialog. The HTML guide explains why native form and button semantics remove custom event work before JavaScript begins.

Debug from the earliest wrong observation

Evidence-led debugging sequence

  1. Reproduce with exact input, viewport, network state, and steps.
  2. Inspect the console and Network panel; identify the first unexpected request, response, exception, or state.
  3. Log the boundary values entering and leaving the suspected function.
  4. Reduce the case until one invariant fails reliably.
  5. Fix the owner of that invariant and leave a focused regression check.

A stack rewrite is not a diagnosis. If the request is correct but rendering is stale, inspect state ownership. If the DOM is correct but the control cannot be operated, inspect semantics and focus. If production serves an old script, inspect asset identity and caching through the deployment guide. The earliest wrong observation usually points to the smallest honest fix.

Sources and further reading

These references support the standards and factual claims in this guide. The decision frameworks and worked examples are original editorial synthesis.

  1. MDN: Using the Fetch API
  2. MDN: AbortController
  3. MDN: Event loop
  4. MDN: DOM events

Questions practitioners ask

Does fetch reject on a 404 response?

No. Fetch rejects for certain request failures, but an HTTP error response still resolves to a Response. Check response.ok or response.status before parsing success data.

Should every UI use a state-machine library?

No. Many interfaces need only a small explicit state value and one render function. Add a library when transition complexity is observed and the dependency simplifies it.

Why cancel requests if stale responses are ignored?

Cancellation saves unnecessary work and expresses ownership. A sequence guard still helps because not every async operation supports cancellation and timing can change between stages.