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> */ }
- Inside
ask(), set the dialog text, show the dialog, and return a promise whoseresolveis stored onthis. - In the Yes / No action handlers, call
this.resolve(true)/this.resolve(false)and close the dialog. - In the list-item controller, declare
confirmas 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.

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.


