Skip to content

Async task & feedback patterns

Every async state has one correct component — treat this page as rules, not suggestions. Structured content that is on its way gets md-skeleton; queued work with no measurable progress gets md-loading-indicator; a running job you can measure gets a determinate md-progress-indicator; state itself is a labelled md-status-dot; destructive cancellation is a blocking md-dialog; and everything that already happened is a snackbar — success plain, error with a retry action, reversible actions with Undo. The demo below runs a whole job queue through those rules.

Live preview — watch the rollup finish, retry the failed webhook, or cancel the running job
Export jobs Shared worker pool - one live region, one snackbar, one rule per state.
Step 3 of 5 - aggregating events (38%) Queued behind 1 job - no worker assigned yet Attempt 2 failed at 09:12 - endpoint returned 503 Completed - 41,208 events, 12.4 MB

"Usage rollup - July" is still running. Cancelling discards the partial file; a restarted job begins again from step 1.

Keep running Cancel export
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<!-- Initial load: ONE live region, every skeleton inside it silent -->
<div role="status" aria-label="Loading export jobs">
  <md-skeleton variant="circular" width="12px" height="12px" announce="false"></md-skeleton>
  <md-skeleton variant="text" lines="2" full-width announce="false"></md-skeleton>
</div>

<md-list label="Export jobs" interaction-mode="multi-action">
  <!-- RUNNING: live labelled dot + determinate progress + cancel affordance -->
  <md-list-item headline="Usage rollup - July">
    <span slot="leading" style="position: relative; display: inline-flex; width: 12px; height: 12px;">
      <md-status-dot state="online" live label="Running"></md-status-dot>
    </span>
    <span slot="supporting-text">
      <span>Step 3 of 5 - aggregating events (38%)</span>
      <md-progress-indicator value="38" max="100" label="Usage rollup progress"></md-progress-indicator>
    </span>
    <md-icon-button slot="trailing" icon="close" size="xs"
      aria-label="Cancel the usage rollup export"></md-icon-button>
    </md-list-item>
    <!-- QUEUED: indeterminate wait, no fake percentages -->
    <md-list-item headline="Invoice PDF batch (412 files)"
      supporting-text="Queued behind 1 job - no worker assigned yet">
      <span slot="leading" style="position: relative; display: inline-flex; width: 12px; height: 12px;">
        <md-status-dot state="neutral" label="Queued"></md-status-dot>
      </span>
      <md-loading-indicator slot="trailing" label="Waiting for a worker"
        style="--md-loading-indicator-size: 28px;"></md-loading-indicator>
      </md-list-item>
      <!-- FAILED: state as words + an inline retry affordance -->
      <md-list-item headline="Webhook redelivery - order.created"
        supporting-text="Attempt 2 failed at 09:12 - endpoint returned 503">
        <span slot="leading" style="position: relative; display: inline-flex; width: 12px; height: 12px;">
          <md-status-dot state="busy" label="Failed"></md-status-dot>
        </span>
        <md-icon-button slot="trailing" icon="refresh" variant="tonal" size="xs"
          aria-label="Retry webhook redelivery"></md-icon-button>
        </md-list-item>
      </md-list>

      <!-- ONE snackbar element serves all three conventions -->
      <md-snackbar></md-snackbar>

      <!-- Destructive cancellation blocks the flow and cannot be click-away dismissed -->
      <md-dialog headline="Cancel this export?" icon="warning" scrim-dismissible="false">
        <p>Cancelling discards the partial file.</p>
        <md-button slot="actions" variant="text">Keep running</md-button>
        <md-button slot="actions" variant="filled">Cancel export</md-button>
      </md-dialog>

<script type="module">
  const bar = document.querySelector('[data-bar]');
  const dialog = document.querySelector('[data-cancel-dialog]');
  const loading = document.querySelector('[data-loading]');
  const jobs = document.querySelector('[data-jobs]');
  const prog = document.querySelector('[data-progress]');
  const cancelBtn = document.querySelector('[data-cancel]');
  const retryBtn = document.querySelector('[data-retry]');
  const queueSpinner = document.querySelector('[data-queue-spinner]');
  const dot = function (id) { return document.querySelector('[data-dot="' + id + '"]'); };
  const status = function (id) { return document.querySelector('[data-status="' + id + '"]'); };

  // Snackbar conventions: one element, one pending-close callback.
  // RULE: the action button never dismisses by itself -> hide('action').
  // RULE: commit/undo on mdClose by reason, never inside mdAction.
  let onClose = null;
  bar.addEventListener('mdAction', function () { bar.hide('action'); });
  bar.addEventListener('mdClose', function (e) {
    const cb = onClose; onClose = null;
    if (cb) cb(e.detail.reason);
  });
  function toast(message, opts) {
    opts = opts || {};
    bar.message = message;
    bar.action = opts.action || '';
    bar.politeness = opts.assertive ? 'assertive' : 'polite';
    bar.autoHide = !opts.sticky;
    bar.closeable = !!opts.sticky;
    bar.autoHideDuration = opts.duration || 4000;
    onClose = opts.onClose || null;
    bar.show();
  }

  // RULE: skeletons render until replaced - swap them for the real rows,
  // never leave them up on error.
  setTimeout(function () {
    loading.style.display = 'none';
    jobs.style.display = '';
    startRollup();
  }, 1400);

  // Determinate progress: raw value against max, text mirror beside the bar.
  let value = 38;
  let timer = null;
  function startRollup() {
    timer = setInterval(function () {
      value += 2;
      if (value >= 100) {
        value = 100;
        clearInterval(timer);
        timer = null;
      }
      prog.value = value;
      status('rollup').textContent = 'Step 3 of 5 - aggregating events (' + value + '%)';
      if (value === 100) finishRollup();
    }, 250);
  }
  function finishRollup() {
    // RULE: mdComplete follows the complete flag, not value === max.
    prog.complete = true;
    const d = dot('rollup');
    d.live = false;
    d.label = 'Finished';
    status('rollup').textContent = 'Completed - 2.1 MB written to exports/usage-2026-07.csv';
    cancelBtn.style.display = 'none';
    toast('Usage rollup finished');
    // The queue drains: the queued job takes the freed worker.
    queueSpinner.remove();
    const inv = dot('invoices');
    inv.state = 'online';
    inv.label = 'Running';
    inv.live = true;
    status('invoices').textContent = 'Rendering invoice 18 of 412...';
  }

  // Cancellation: destructive -> dialog, then a reversible Undo snackbar.
  cancelBtn.addEventListener('click', function () {
    if (timer) dialog.open = true;
  });
  document.querySelector('[data-keep]').addEventListener('click', function () {
    dialog.open = false;
  });
  document.querySelector('[data-confirm]').addEventListener('click', function () {
    dialog.open = false;
    clearInterval(timer);
    timer = null;
    const at = value;
    prog.style.display = 'none';
    const d = dot('rollup');
    d.state = 'offline';
    d.live = false;
    d.label = 'Cancelled';
    status('rollup').textContent = 'Cancelled at ' + at + '% - partial output discarded';
    cancelBtn.style.display = 'none';
    toast('Usage rollup cancelled', {
      action: 'Undo',
      duration: 6000,
      onClose: function (reason) {
        if (reason !== 'action') return;   // auto/close = committed
        prog.style.display = '';
        d.state = 'online';
        d.live = true;
        d.label = 'Running';
        cancelBtn.style.display = '';
        startRollup();
      }
    });
  });

  // Retry: first attempt fails again (sticky error snackbar with a Retry
  // action), the next one lands.
  let attempt = 2;
  function runRetry() {
    attempt += 1;
    retryBtn.disabled = true;
    const d = dot('webhooks');
    d.state = 'online';
    d.live = true;
    d.label = 'Retrying';
    status('webhooks').textContent = 'Attempt ' + attempt + ' - delivering to hooks.acme-billing.com...';
    setTimeout(function () {
      if (attempt < 4) {
        d.state = 'busy';
        d.live = false;
        d.label = 'Failed';
        status('webhooks').textContent = 'Attempt ' + attempt + ' failed - endpoint returned 503';
        retryBtn.disabled = false;
        toast('Webhook redelivery failed (503)', {
          assertive: true,
          sticky: true,
          action: 'Retry',
          onClose: function (reason) { if (reason === 'action') runRetry(); }
        });
      } else {
        d.state = 'online';
        d.live = false;
        d.label = 'Delivered';
        status('webhooks').textContent = 'Delivered on attempt ' + attempt + ' - 200 OK in 340 ms';
        retryBtn.style.display = 'none';
        toast('Webhook delivered');
      }
    }, 1600);
  }
  retryBtn.addEventListener('click', function () { runRetry(); });
</script>
ComponentRole in this screen
md-skeletonThe initial paint. Shapes match the real rows (a 12px circle plus two text lines), and one wrapping role="status" region announces the load while every skeleton inside is announce="false".
md-list + md-list-itemThe job queue. interaction-mode="multi-action" lets each row host its own trailing control (cancel, retry) with independent focus and click semantics.
md-status-dotPer-job state. The running job’s dot is live + labelled, which makes it a role="status" live region — state changes are announced. Every other dot carries a static label, so no state exists as colour alone.
md-progress-indicatorDeterminate progress for the one measurable job: raw value against max, a text percentage beside the bar, and complete to play the closing animation when it lands.
md-loading-indicatorThe queued job’s indeterminate wait — no fake percentages while no worker is assigned.
md-icon-buttonInline cancel (icon="close", size="xs") and retry (icon="refresh", tonal) affordances, each with a job-specific aria-label.
md-dialogCancellation confirmation. Destructive, so scrim-dismissible="false" — a stray click outside cannot discard the partial file.
md-buttonThe dialog’s slotted actions — dismissive “Keep running” (text) on the leading side of the confirming “Cancel export” (filled). Slotted buttons never close the dialog by themselves; each handler sets open = false itself.
md-snackbarOne element, three conventions: plain auto-hiding success, sticky assertive error with a Retry action, and a reversible Undo.
  • One loading region announces once. Twenty skeletons each saying “Loading” is the most common defect in this pattern — here the wrapping div is the single role="status" element and every md-skeleton inside it is announce="false". On failure, replace the skeletons with an error state; they have no timeout of their own.
  • Completion is explicit, not inferred. md-progress-indicator fires mdComplete only after you set complete = true — reaching value === max changes nothing on its own. The demo sets the flag when the rollup lands, which plays the closing animation and removes the bar instead of leaving a finished bar that reads as stalled.
  • Undo commits on mdClose, never in mdAction. The snackbar’s action button does not dismiss it; the handler calls hide('action') and the mdClose listener branches on detail.reason. Handling the action in both events runs your rollback twice.
  • Errors persist, successes evaporate. The failure snackbar is auto-hide="false" + closeable + politeness="assertive" — a 4-second toast is the wrong vehicle for something that needs a decision. The success message keeps the polite default and disappears on its own.
  • State is words, not colour. Every md-status-dot carries a label, so WCAG 1.4.1 holds even under forced-colors where all six states collapse to one system colour. Only the genuinely changing dot uses live — a pulse on every row would mean nothing.
  • Several jobs running at once: M3 says show the group total, not a bar per row — replace the per-job indicators with one linear md-progress-indicator above the list (value="3" max="12" label="Exporting 12 jobs").
  • Queued work whose total becomes known: start an md-progress-indicator as indeterminate, then clear indeterminate before setting value/max — it silently overrides the number otherwise. Do not swap an md-loading-indicator for a determinate bar mid-operation; M3 explicitly discourages that transition.
  • Connection lost: a persistent snackbar — auto-hide="false", closeable, politeness="assertive" — that you hide() yourself when the socket reconnects.
  • Dense ops console: move the queue into an md-table with size="small" status dots and a negative density rung; the dot’s logical insets and the linear track both mirror automatically in RTL.

Keep entered values visible while a real request runs. Use the submit button’s loading state, make the editable region inert, and keep a dismissive action available. A single persistent status line announces saving, failure or success; the button’s built-in spinner is already decorative. On failure, retain the draft and let the same submit action retry. A skeleton belongs to content that has not arrived yet, not to a filled-in form the user is saving.

This React example receives saveProfile(data, { signal }) from the application. It must return a promise for the real request and pass the signal to its transport. There are no display timers or simulated delays.

import { useEffect, useId, useRef, useState, type FormEvent } from 'react';
import { MdButton, MdDialog, MdTextField, useOverlay } from '@awc-ui/react';
interface ProfileDialogProps {
saveProfile(data: FormData, options: { signal: AbortSignal }): Promise<unknown>;
onClosed(): void;
}
export function ProfileDialog({ saveProfile, onClosed }: ProfileDialogProps) {
const dialog = useRef<HTMLMdDialogElement>(null);
const form = useRef<HTMLFormElement>(null);
const name = useRef<HTMLMdTextFieldElement>(null);
const request = useRef<AbortController | null>(null);
const formId = useId();
const [busy, setBusy] = useState(false);
const [failed, setFailed] = useState(false);
const [message, setMessage] = useState('');
const overlay = useOverlay(dialog, {
onClosing: () => request.current?.abort(),
onClosed,
});
useEffect(() => () => request.current?.abort(), []);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const currentForm = form.current;
const currentName = name.current;
if (request.current || !dialog.current?.open || !currentForm || !currentName) return;
// Lock before awaiting validation so two activations cannot race.
const controller = new AbortController();
request.current = controller;
try {
if (!(await currentName.reportValidity()) || controller.signal.aborted) return;
setBusy(true);
setFailed(false);
setMessage('Saving your profile…');
await saveProfile(new FormData(currentForm), { signal: controller.signal });
if (!controller.signal.aborted) setMessage('Your profile has been saved.');
} catch (error) {
if (!controller.signal.aborted) {
setFailed(true);
setMessage('Your profile could not be saved. Your changes are still here; try again.');
}
} finally {
if (request.current === controller) request.current = null;
if (!controller.signal.aborted) setBusy(false);
}
}
return (
<MdDialog ref={dialog} headline="Edit profile" divider {...overlay}>
<form id={formId} ref={form} noValidate onSubmit={submit}>
<div ref={(element) => { if (element) element.inert = busy; }}>
<MdTextField ref={name} name="displayName" label="Display name"
required autocomplete="name" reserveSupportingSpace />
</div>
<p role="status" aria-atomic="true"
style={{ color: failed ? 'var(--md-sys-color-error)' : 'var(--md-sys-color-on-surface-variant)' }}>
{message}
</p>
</form>
<MdButton slot="actions" type="button" variant="text"
onMdClick={() => dialog.current?.close()}>Close</MdButton>
<MdButton slot="actions" type="submit" loading={busy}
ref={(button) => { button?.setAttribute('form', formId); }}>
{busy ? 'Saving…' : failed ? 'Retry save' : 'Save'}
</MdButton>
</MdDialog>
);
}

The footer button is associated with the real form through its native form attribute. The refs set form and the native inert property directly, which works with the library’s React 18+ support and its React 18 type definitions. In React 19, the editable region can instead use inert={busy}. Clicking it and pressing Enter in the single-line field use the same submit handler. noValidate delegates presentation to the explicit Core reportValidity() call, which keeps constraint errors inline. Keep the status line outside the inert region so announcements remain available during the request. Do not add a second live loader or an error snackbar announcing the same message.

useOverlay ignores nested picker close events, aborts through onClosing, and calls onClosed only after the dialog’s exit completes. The abort checks prevent a late response from changing dismissed UI. The caller should unmount the dialog in onClosed; reopening mounts a fresh session. If saving creates a lasting server-side job, cancellation of that job needs an application API in addition to aborting the client request.

  • Recipe: Server fleet status — status dots and live health at dashboard scale.
  • Recipe: CSV import wizard — a single long-running job with staged determinate progress.
  • Component manuals: md-snackbar — the full event contract and queueing rules; md-progress-indicator — the complete/mdComplete lifecycle and the determinate/indeterminate switch.