Update a Progress Bar using Turbo Streams (using Custom Actions)

When Turbo Streams was announced they allowed you to do a handful actions, like prepend, replace, focusing on your HTML.

Fast-forward, and through this PR it’s now possible to create your own actions. This means everything you can normally do with JavaScript you can do using Turbo Streams.

There is this great article by Marco Roth that explains the basics—check it out.

Setting the stage

I have a report feature, that gathers data, does some calculations and then creates a PDF off of it.

This is some intensive data wrangling, so it makes sense to put it into one or many background jobs. For a great UX the goal is to let the user know about the progress.

What is needed:

  • report model (status: %w[pending ready]);
  • a stimulus controller to show the progress (1..100%);
  • turbo stream, with custom action, to update the progress in the view.

This article will not cover every single detail to get this functionality up and running from zero, but only touches upon the important bits needed for the requirements.

Once the report is requested by the user, respond with a turbo_stream and inject a progress bar.

turbo_stream.prepend("reports", partial: "reports/in_progress")

This will add the reports/_in_progress.html.erb add top of the #reports element.

<div>
  <h3>Fasten your seatbelt! We're turbo-charging a dazzling data display just for you.</h3>

  <span data-controller="progress-bar" data-progress-bar-amount-value="0" id="progress_bar" class="block w-0 h-2 bg-blue-500 rounded transition-all"></span>
</div>

This needs a Stimulus controller to update the progress bar. Let’s create it.

// app/javascript/controllers/progress_bar_controller.js
import { Controller } from "stimulus";

export default class extends Controller {
  static values = { amount: Number }

  connect() {
    this.#updateProgress();
  }

// private

  amountValueChanged() {
    this.#updateProgress();
  }

  #updateProgress() {
    this.element.style.width = `${this.amountValue}%`;
  }
}

All this Stimulus controller does is change the width of its element whenever the data-progress-bar-amount-value changes. This is because of the amountValueChanged() function.

Now in each step of the report creation, fire off a turbo_stream to update the amount data attribute.

Let’s imagine the following object:

# app/models/report/creator.rb
class Report::Creator
  def initialize(report)
    @report = report
  end

def create
  collect
  wrangle
	# …
  finish!
end

private

def collect
  # collect data

  update_progress(amount: 1)
end

def wrangle
  # wrangle data

  update_progress(amount: 10)
end

# etc.

def finish!
  @report.ready!

  update_progress(amount: 100)
  # TODO: probably update the screen with button to download the report
end

def update_progress(amount:)
  action = turbo_stream_action_tag(:set_dataset_attribute, value: value)

  ActionCable.server.broadcast("reports", action)
end

In each “step” the update_progress method is called. This method needs some explanation.

First it uses set_dataset_attribute from turbo-power. It’s a great add-on to your Turbo-powered Rails app.

It then broadcasts the created action to the “reports” stream (make sure you add it!). I’ve not stumbled upon another working solution, but am curious if you know of any.

And these are all the high-level steps needed to get a great UX for long-running background jobs. I think it’s pretty great this is now possible with almost no JavaScript written!

Get UI & Product Engineering Insights for Rails Apps (and product updates!)

Published at . Have suggestions or improvements on this content? Do reach out.

UI components for Ruby on Rails apps

$ 129 one-time
payment

Get Access
  • One-time Payment

  • Access to the Entire Library

  • Built for Ruby on Rails (inc. Rails 8)

  • Designed with Tailwind CSS and Enhanced with Hotwire

  • Updates for 12 months