Coordinating Rails and JavaScript with Custom Turbo Actions

We recently built a quiz feature that needed to celebrate correct answers with confetti animations. The challenge wasn’t the confetti itself but coordinating between our Rails controller and the frontend JavaScript without mixing concerns or adding complexity.

The solution led us to discover how Turbo Stream’s extensibility can solve coordination problems in surprisingly elegant ways.

Starting Simple: A Custom Confetti Action

For our quiz, we started with something specific: a custom action that triggers confetti directly. Our Rails controller becomes remarkably clean:

class QuizController < ApplicationController
  def create
    if correct_answer? 
      render turbo_stream: turbo_stream.action(:confetti)
    else
      # Handle incorrect answer
    end
  end
end

The controller focuses purely on business logic, validating answers and deciding outcomes. When the answer is correct, it tells Turbo to trigger confetti, and that’s it.

To make this work, we extend Turbo.StreamActions with custom behavior:

Turbo.StreamActions.confetti = function () {
  launchConfetti();
};

Making It Generic: The Trigger Action

The real insight came when we needed similar coordination for other features. Instead of creating specific actions like confetti, hideSpinner, closeDialog or playSound, we could create one generic action that triggers JavaScript events.

Now our quiz controller can trigger any event by specifying it in the target:

class QuizController < ApplicationController
  def create
    if correct_answer? 
      render turbo_stream: turbo_stream.action(:trigger, "launch-confetti")
    else
      ...
    end
  end
end

This works with one simple JavaScript function that transforms any Turbo Stream response into a custom DOM event:

Turbo.StreamActions.trigger = function () {
  document.dispatchEvent(new Event(this.target));
};

Frontend Components as Event Listeners

With events, our frontend components become listeners that can react independently:

export default class extends Controller {
  connect() {
    document.addEventListener("launch-confetti", this.launchConfetti);
  }
  
  launchConfetti() {
    🎉🎉🎉
  }
}

When a correct answer triggers the “launch-confetti” event, our Stimulus controller responds with the celebration animation.

This pattern opens up many possibilities. Your Rails controllers can trigger events like:

  • hide-loading-spinner after data loads successfully
  • play-notification-sound for important alerts
  • update-progress-bar during long-running processes
  • trigger-analytics-event for user behavior tracking

Each event can have multiple listeners, and listeners can be added or removed without affecting server code.

Why This Approach Wins

This pattern keeps Rails controllers focused on data and business logic while enabling rich frontend interactions. Testing becomes easier because you verify events fire without testing implementation details. Multiple components can coordinate without coupling to each other.

Custom Turbo Stream actions remind us that extending existing tools often beats adding new dependencies. Starting with something specific like confetti and then generalizing to events shows how the best abstractions often emerge from real use cases.