event-driven programming
user interface
javascript
button click
web development

Await a button click

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Waiting for a button click with await can make UI workflows easier to read than deeply nested event callbacks. The core idea is to wrap one event in a promise, then compose it with other async steps. The important part is lifecycle safety so listeners are removed on success, timeout, or cancellation.

Why await Helps In UI Flow

Event driven code often combines multiple user steps, such as open dialog, confirm action, then submit network request. Callback chains can hide intent and make cleanup harder.

Promise wrappers let you write these interactions as linear steps while still using browser events.

Minimal One Click Await Helper

This helper resolves once and automatically removes the listener.

javascript
1function waitForClick(button) {
2  return new Promise((resolve) => {
3    const handler = (event) => {
4      button.removeEventListener("click", handler);
5      resolve(event);
6    };
7
8    button.addEventListener("click", handler);
9  });
10}
11
12async function runFlow() {
13  const button = document.getElementById("saveBtn");
14  if (!button) throw new Error("button not found");
15
16  await waitForClick(button);
17  console.log("clicked");
18}
19
20runFlow().catch(console.error);

This is enough for simple screens where cancellation is not needed.

Add Cancellation With AbortController

Real applications need cancellation for route changes, modal close, or timeout.

javascript
1function waitForClickAbortable(button, signal) {
2  return new Promise((resolve, reject) => {
3    if (signal?.aborted) return reject(new Error("aborted"));
4
5    const onClick = (event) => cleanup(() => resolve(event));
6    const onAbort = () => cleanup(() => reject(new Error("aborted")));
7
8    function cleanup(done) {
9      button.removeEventListener("click", onClick);
10      signal?.removeEventListener("abort", onAbort);
11      done();
12    }
13
14    button.addEventListener("click", onClick, { once: false });
15    signal?.addEventListener("abort", onAbort, { once: true });
16  });
17}
18
19async function runAbortableFlow() {
20  const btn = document.getElementById("saveBtn");
21  const controller = new AbortController();
22
23  setTimeout(() => controller.abort(), 5000);
24
25  try {
26    await waitForClickAbortable(btn, controller.signal);
27    console.log("user confirmed in time");
28  } catch (err) {
29    console.log(err.message);
30  }
31}

Now the listener is cleaned up in both resolve and reject paths.

Await Repeated Clicks Safely

For repeated interactions, prefer a loop around a single await helper rather than adding new handlers on every render.

javascript
1async function listenForThreeClicks(button) {
2  for (let i = 0; i < 3; i += 1) {
3    const event = await waitForClick(button);
4    console.log("click number", i + 1, "at", event.timeStamp);
5  }
6}

This keeps control flow explicit and avoids leaked listeners.

Integrating With Framework Components

In React, Vue, or similar frameworks, attach listeners in lifecycle hooks and abort on unmount. Avoid helper functions that capture stale component references.

A good pattern is to pass only current element reference and external cancellation signal into the helper. That keeps ownership boundaries clear between framework lifecycle and raw DOM events.

Testing Strategies

To test these helpers:

  • create a button in test DOM,
  • start waiting function,
  • dispatch click event programmatically,
  • assert resolution and side effects,
  • verify that additional clicks do not trigger old listeners.

Also test cancellation path to confirm no hanging promises remain.

Add Timeout As A First Class Case

Many user flows should not wait forever. You can compose click waiting with a timeout promise and clean up whichever completes last.

javascript
1function withTimeout(promise, ms) {
2  return Promise.race([
3    promise,
4    new Promise((_, reject) =>
5      setTimeout(() => reject(new Error(\"timeout\")), ms)
6    ),
7  ]);
8}

Timeout handling keeps UI state machine predictable when users leave a screen idle.

Common Pitfalls

  • Forgetting to remove listeners and creating memory leaks.
  • Resolving promise multiple times due to duplicated handlers.
  • Ignoring element null checks before waiting on events.
  • Not handling cancellation when component unmounts.
  • Mixing DOM lookup, business logic, and event orchestration in one large function.

Summary

  • Promise wrapping lets you await button clicks with clear sequential flow.
  • Listener cleanup is required in both success and cancellation paths.
  • AbortController is a practical way to cancel waits on lifecycle changes.
  • Repeated waits should reuse one helper to avoid handler leaks.
  • Strong separation of UI event orchestration and business logic improves maintainability.

Course illustration
Course illustration

All Rights Reserved.