Premise
Here’s a pattern you’ll find in any app with inline-editable fields: change a value, auto-save it, show a little toast that confirms the save. It feels polished when it works. The problem is where that toast comes from.
The standard Hotwire approach is to let the server send the toast back as a Turbo Stream, something like turbo_stream.append "toast". That works, but the feedback is tied to the round trip latency. If the server takes 800ms to respond (database write, background job, whatever), the user stares at a silent form for 800ms wondering if anything happened. On a slow connection it’s even worse.
In Challenge 21 we explored optimistic UI at the DOM level, swapping a favorite button’s state instantly via an inline <turbo-stream> template and reconciling later with a Turbo 8 morph. This time the goal is different: we’re not morphing HTML, we’re firing a side-channel notification (a toast) the instant the request leaves the browser, before we have a response. If the server comes back with an error, a second toast follows up with the bad news. Instant feedback in the happy path, honest feedback in the sad path. No radio silence.
There are two twists here. First, instead of one monolithic controller we’ll build two small ones, an auto-save controller and a toast controller, and link them through a Stimulus outlet. The auto-save controller can call this.toastOutlet.show(...) without knowing anything about the toast’s internals. Second, the toast controller wraps Notyf, a tiny (~3 KB) notification library. Wrapping a third-party lib in a Stimulus controller is a pattern you’ll reach for often: the controller owns the lifecycle, the outlet exposes a clean API, and the rest of your app never imports the library directly.
Starting Point
We have a simple settings form with a few fields. The server introduces an artificial delay (800-1500ms) to simulate a real-world round trip, and returns a 422 error roughly 20% of the time so you can test the failure path.
The HTML is fully wired: each input has a data-action="change->auto-save#save" attribute, the form declares a data-auto-save-toast-outlet pointing at the toast container, and a <div id="toast-container" data-controller="toast"> sits at the bottom of the page. Notyf is already in the import map and its CSS is loaded via a CDN link tag.
Both controllers are registered but their methods are empty. The outlet declaration and data-action bindings are in place; you just need to fill in the logic.
Challenge
Fill in the two empty controller methods:
-
toast_controller#show(message, variant)- Notyf is already instantiated inconnect(). Implementshow(message, variant)so it callsthis.notyf.success(message)orthis.notyf.error(message)depending on the variant. Hint: Notyf useserrorwhere our API saysdanger, so you’ll need to map the name. -
auto_save_controller#save()- Build aFormDatafrom the form, callthis.toastOutlet.show("Saved!", "success")before thefetch, then send the request. If the response comes back with a non-ok status, fire a second toast:this.toastOutlet.show("Save failed", "danger"). The outlet wiring (static outlets = ["toast"]and the HTML attribute) is already in place.
The order matters: the optimistic toast fires the instant the request dispatches, not when the response arrives.
Here’s the target behavior: the moment you change a field, a green “Saved!” toast slides in immediately. If the server returns an error (the demo server fails randomly ~20% of the time), a red “Save failed” toast follows a beat later.

Reminder: the Node server simulates a Rails backend. If you make changes to index.js, restart with npm start.
Teaser
Two directions you could take this further. First, make the messages configurable per form via Stimulus values: data-auto-save-success-message-value="Profile updated" overrides the default “Saved!” text, so the same controller serves every auto-saving form without changes. Second, try replacing Notyf with a hand-rolled toast built from a <div> and CSS keyframes. The controller’s show() signature stays the same; only the internals change. That’s the benefit of wrapping the library in a controller: swapping implementations is invisible to every outlet consumer.
Caveat
Watch the debounce timing. If the user tabs through three fields in quick succession, you don’t want three overlapping requests and three “Saved!” toasts stacking up. A debounce of 300-500ms collapses rapid edits into a single request. But the toast should fire on the leading edge, when the request actually dispatches, not on the trailing edge of the debounce timer. Otherwise you lose the instant feeling that makes optimistic UI worth doing in the first place.


