New releases

Hotwire Club tooling is now open-source

Explore the agentic skills pack and the MCP server for building assistant workflows.

Stimulus - Promise-Returning Outlet Methods

Turn a Stimulus controller into an await-able API by returning a promise from an outlet method and resolving it from a dialog's button handlers.

Stimulus - Promise-Returning Outlet Methods

Premise

Stimulus controllers usually talk to each other via dispatched events. That works great for fire-and-forget notifications, but events have no return channel. Sometimes you want a request/response shape instead: ask a controller a question, wait for the answer, act on it.

Enter the deferred-promise pattern. The trick is simple: create a promise, stash its resolve function on the controller instance, and settle it later from a different event handler. Wrap that in a public method and you’ve turned a Stimulus controller into an await-able mini-API. Combine it with outlets (Challenge 51 used them for the toast controller) and any other controller can call it like a library function.

Starting Point

We have a list of items, each with a delete button. Deletion requires confirmation. A <dialog>-based confirm controller already exists with Yes / No buttons, and a list-item controller handles the delete action.

But right now the two talk via a custom event: the list-item controller dispatches a confirm:ask event, and then has to listen for a confirm:answer event to find out what the user clicked. The delete logic is split across two handlers (before-ask and after-answer), with state smeared in between to remember which item was pending deletion.

The HTML is fully wired: the list items live statically in index.html, so you can see every data attribute in one place, including the data-list-item-confirm-outlet attribute you’ll need later. The server handles the actual deletion via POST-redirect-GET.

Challenge

Rewrite the confirm controller to expose a single public method:

async ask(message) { /* returns Promise<boolean> */ }
  1. Inside ask(), set the dialog text, show the dialog, and return a promise whose resolve is stored on this.
  2. In the Yes / No action handlers, call this.resolve(true) / this.resolve(false) and close the dialog.
  3. In the list-item controller, declare confirm as an outlet and collapse the two-handler dance into one linear method:
async delete(event) {
  event.preventDefault()
  if (await this.confirmOutlet.ask("Delete this item?")) {
    event.target.closest("form").requestSubmit()
  }
}

Here’s the target behavior: clicking a delete button opens the dialog, “Yes” removes the item, “No” leaves it alone, and the list-item controller reads top to bottom like synchronous code.

List of items where clicking delete opens a confirmation dialog; confirming removes the item, declining keeps it

Reminder: the Node server simulates a Rails backend. If you make changes to index.js, restart with npm start.

Teaser

What should happen when the user dismisses the dialog with Escape, or by clicking the backdrop? Right now, nothing resolves the promise, so the caller awaits forever. Wire those escape hatches up to resolve(false) so callers never hang. Hint: the <dialog> element fires a close event that covers Escape for free.

Caveat

If ask() is called while a previous call is still pending, you’ll overwrite this.resolve and strand the first caller’s promise, which then never settles. Depending on your UX you can queue calls, reject the previous promise, or simply guard against concurrent asks by refusing (or ignoring) a second call while the dialog is open. Whatever you pick, make it deliberate: a promise that silently never resolves is one of the nastiest bugs to track down.

This is The Hotwire Club

52 hands-on challenges with detailed solutions, published biweekly since 2023. Subscribe to access all solutions and join the Discord community.

Subscribe on Patreon

More from

Stimulus - Optimistic Toast Notifications with Auto-Save
26 May 2026

Build an auto-save form that fires a toast notification the instant it submits, not when the server responds, using Stimulus outlets.

Turbo Frames - Error Boundaries
28 April 2026

Build a reusable Stimulus controller that catches Turbo Frame failures and shows elegant error states with retry - inspired by React's Error Boundaries.

Turbo Streams - Custom Stream Actions - pushState
14 April 2026

Synchronize browser history with Turbo Stream responses using a custom push_state action, a Stimulus controller, and the popstate event.

Cookies
essential