ActiveStorage Direct Upload with Stimulus

3d rendering of a person holding a compass

In two previous articles I explored first previewing images before upload and then a drag & drop feature. In this article I am going, once again, extend the functionality by adding a direct upload feature.

Direct Upload in ActiveStorage allows files to be uploaded directly from the user to the cloud storage service (eg. S3), without touching your app’s server. This is mostly useful for larger files like audio and video, but nonetheless useful for images too.

Let’s start, also this time, with the HTML where the previous article ended:

<div data-controller="image-preview dropzone" data-dropzone-image-preview-outlet="#image-preview" data-action="dragover->dropzone#dragOver dragleave->dropzone#dragLeave drop->dropzone#drop" id="image-preview>
  <img data-image-preview-target="canvas" hidden class="object-cover size-48">

  <input type="file" accept="image/*" data-image-preview-target="source" data-dropzone-target="input" data-action="image-preview#show" hidden>
</div>

Let’s add some attributes to it:

<div data-controller="image-preview dropzone direct-upload" data-dropzone-image-preview-outlet="#image-preview" data-action="dragover->dropzone#dragOver dragleave->dropzone#dragLeave drop->dropzone#drop" data-direct-upload-url-value="<%= rails_direct_uploads_url %>" id="image-preview>
  <img data-image-preview-target="canvas" hidden class="object-cover size-48">

  <input type="file" accept="image/*" data-image-preview-target="source" data-dropzone-target="input" data-direct-upload-target="input" data-action="image-preview#show change->direct-upload#now" hidden>
  <span data-direct-upload-target="progressBar" class="block h-1 bg-gray-900 rounded-full" style="width: 0%"></span>
</div>

From top to bottom, this is what’s added:

  • direct-upload to data-controller;
  • added data-direct-upload-url-value="<%= rails_direct_uploads_url %>";
  • added data-direct-upload-target="input" to the input field;
  • added change->direct-upload#now to the input’sdata-action;
  • and lastly a span-element for the upload progress.

That’s a fair amount of code added! Let’s now write the direct_upload_controller.js.

import { Controller } from "@hotwired/stimulus";
import { DirectUpload } from "@rails/activestorage";

export default class extends Controller {
  static targets = ["input", "progressBar"];
  static values = { url: String };

  now() {
    Array.from(this.inputTarget.files).forEach(file => this.#uploadFile(file));

    this.inputTargets.forEach(input => input.value = null);
  }
}

Alright this needs DirectUpload from @rails/activestorage. So either install it using NPM or pin it using importmaps.

The now() function calls #uploadFile() for each file added to the input element.

// …

export default class extends Controller {
  // …

  // private
  #uploadFile(file) {
    new DirectUpload(
      file,
      this.urlValue,
      this // callback directUploadWillStoreFileWithXHR(request)
    ).create((error, blob) => {
      if (error) {
        console.log(error);
      } else {
        this.#createHiddenInput(blob);
      }
    })
  }
}

It creates a new DirectUpload instance from @rails/activestorage. this references the current object, used as a callback for directUploadWillStoreFileWithXHR. Let’s create the functions used here.

// …

export default class extends Controller {
  // …

  // private

  // …

  #createHiddenInput(blob) {
    const hiddenField = document.createElement("input");

    hiddenField.setAttribute("id", `attachment_${blob.filename}`);
    hiddenField.setAttribute("type", "hidden");
    hiddenField.setAttribute("value", blob.signed_id);
    hiddenField.name = this.element.name;

    this.element.appendChild(hiddenField);
  }

  directUploadWillStoreFileWithXHR(request) {
    request.upload.addEventListener("progress", (event) => {
      this.#progressUpdate(event);
    })
  }
}

The #createHiddenInput method generates a hidden form input containing the uploaded file’s metadata, appending it to the DOM. The directUploadWillStoreFileWithXHR method attaches a progress event listener to the upload request, to show the progress of the upload process.

Now let’s create the #progressUpdate function:

// …

export default class extends Controller {
  // …

  // private

  // …
  #progressUpdate(event) {
    const progress = (event.loaded / event.total) * 100;

    this.progressBarTargets.forEach((progressBar) => {
      progressBar.style.width = `${progress}%`;
    })
  }
}

Wow—that is all! Now, when you select or drag&drop an image:

  • you will see a preview of it;
  • it is uploaded directly to your Cloud Storage Provider.

What’s next?

There are a few more things to take care off.

Cross-Origin Resource Sharing (CORS)

For direct uploads to work, you need to set up CORS. This is needed to allow the browser to make direct upload requests. The official docs has some decent info around this topic.

Purge attachments

Another thing to keep in mind is that with directly uploaded files, they might never be attached to a record. A simple solution is to have a job run regularly. Something like this:

class PurgeUnattachedJob < ApplicationJob
  def perform
    ActiveStorage::Blob
      .unattached
      .where(created_at: ..1.day.ago)
      .find_each(&:purge_later)
  end
end

And that concludes this trilogy of image uploads with Rails’ ActiveStorage together with Stimulus.

Need an alternative to S3 to store your images?

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

$ 99 one-time
payment

Get Access
  • One-time Payment

  • Access to the Entire Library

  • Built for Ruby on Rails

  • Designed with Tailwind CSS and Enhanced with Hotwire

  • Updates for 12 months