Ruby on Rails Drag and Drop Uploads with Active Storage, Stimulus.js and Dropzone.js
Tags: rails, javascript, ruby, tutorial
Published: 2019-10-12 02:54:18

---
title: Ruby on Rails Drag and Drop Uploads with Active Storage, Stimulus.js and Dropzone.js
published: true
tags: rails, javascript, ruby, tutorial
canonical_url: https://web-crunch.com/rails-drag-drop-active-storage-stimulus-dropzone/
---

This tutorial is about using Active Storage from Ruby on Rails 6.0 to create a drag and drop upload experience from scratch using Stimulus.js and Dropzone.js.

[Download Source Code](https://github.com/justalever/drag_and_drop_active_storage)

{% youtube fg_8qJ-su6A %}


### Objective

The goal of this experience is to introduce drag and drop functionality and tie it to Rails Active Storage behind the scenes. I want to be able to drag and drop multiple files for upload as well as pass options that regulate what conditions can be passed around those files. Those conditions could be anything from image type to file size and more.

**Dependencies:**

- [Stimulus JS](https://dev.to/scottw/stimulus-2jio)
- [Dropzone JS](https://www.dropzonejs.com/)
- [Webpack/Rails 6.0](https://rubyonrails.org/)
- [Kickoff Tailwind](https://github.com/justalever/kickoff_tailwind) (My personal starter template for Rails apps)

### How we solve the problem

With Webpack support now in Rails, I plan to leverage a couple of JavaScript libraries to help handle a lot of the drag and drop functionality. On top of this, I use some of the built-in JavaScript from `@rails/activestorage` to create a direct upload pattern. Ultimately, we will still use serverside rendered UI which is normal for a traditional Ruby on Rails application but enhance it with JavaScript to feel more dynamic.

## Creating the app

This guide assumes you’ll leverage my kickoff\_tailwind starter template. It’s completely optional to use but does save some configuration time. I mostly use this to save time for screencasts.

```bash
$ rails new active_storage_drag_and_drop -m kickoff_tailwind/template.rb --webpack=simulus

```

Running the script above will create a new rails app and pass my template through. To add to that effect we leverage [Stimulus JS](https://dev.to/scottw/stimulus-2jio) in this tutorial. That gets installed on the fly by passing `--webpack=stimulus`

Stimulus.js has aspects of Vue, React, etc.. but is designed to enhance the front-end of a Rails application rather than rewrite it with a new framework. It works much like the Rails controller concept where you define a controller with corresponding methods/actions that actually do something.

I may do an overview/guide of Stimulus.js coming up but consider this a healthy taste of what is possible.

Running this command should get us close to being ready to start the app.

#### Dropzone.js

Another really nice dependency I decided to utilize is the [Dropzone.js](https://www.dropzonejs.com/) library. There are a variety of ways to harness the API around this library and we’ll hook into some. The general idea is to get files of our choice to upload via Active Storage using drag and drop functionality. Dropzone.js helps solve many problems around that type of experience.

Install dropzone by running the following in your terminal from within your Rails application root folder.

```bash
$ yarn add dropzone

```

That does it for `node_module` dependencies. Let’s get some Rails logic in order next.

### Install Active Storage

[Active Storage](https://edgeguides.rubyonrails.org/active_storage_overview.html) support is made possible by running one command in a new Rails app. Run the following command and migrate the database to set this up. **Be sure not to skip this step**. It’s crucial for anything here to work going forward.

```bash
$ rails active_storage:install

```

This copies a migration file to the app which adds the necessary tables to utilize uploads.

```bash
$ rails db:migrate

== 20190915152618 CreateActiveStorageTables: migrating ========================
-- create_table(:active_storage_blobs, {})
   -> 0.0028s
-- create_table(:active_storage_attachments, {})
   -> 0.0025s
== 20190915152618 CreateActiveStorageTables: migrated (0.0054s) ===============

```

### Creating a resource for the uploads

We need a form to tie our Active Storage uploads to. I’ll use a `Post` model as an example. We’ll assume a `Post` will have `title`, `body` and `user_id` columns in the database.

Below I scaffold a `Post` to save some time.

```bash
$ rails generate scaffold Post title:string body:text user:references

      invoke active_record
      create db/migrate/20190915153310_create_posts.rb
      create app/models/post.rb
      invoke test_unit
      create test/models/post_test.rb
      create test/fixtures/posts.yml
      invoke resource_route
       route resources :posts
      invoke scaffold_controller
      create app/controllers/posts_controller.rb
      invoke erb
      create app/views/posts
      create app/views/posts/index.html.erb
      create app/views/posts/edit.html.erb
      create app/views/posts/show.html.erb
      create app/views/posts/new.html.erb
      create app/views/posts/_form.html.erb
      invoke test_unit
      create test/controllers/posts_controller_test.rb
      create test/system/posts_test.rb
      invoke helper
      create app/helpers/posts_helper.rb
      invoke test_unit
      invoke jbuilder
      create app/views/posts/index.json.jbuilder
      create app/views/posts/show.json.jbuilder
      create app/views/posts/_post.json.jbuilder
      invoke assets
      invoke scss
      create app/assets/stylesheets/posts.scss
      invoke scss
      create app/assets/stylesheets/scaffolds.scss

```

The command above will create a new `Post` resource with full RESTful routing. A scaffold in Rails is a quick was to create the complete CRUD concept with next to no effort.

Notice I didn’t include anything to do with an image or file upload here. This is intended.

I also passed `user:references` which tells Rails to create a `user_id` column on the `posts` table (once migrated) as well as an index for that column.

Note: If you **are not** using my kickoff\_tailwind template you probably want to skip the `user:references` addition to this command. The reason for this is that there is already a `User` model in place when creating a new app when referencing the template.

Scaffolds generate a `scaffolds.scss` file and `posts.scss` file. We can delete both since we are using Tailwind in this guide.

One more migration and we should be set

```bash
$ rails db:migrate

```

Heading to `localhost:3000/posts` should give you an index of all the posts.

### Locking down the controller

Even though we are currently signed out I can still create a new post `localhost:3000/posts/new` which probably isn’t a good thing. My kickoff\_tailwind template includes [devise](https://github.com/plataformatec/devise). As a result we can make a user sign in first before hitting the `posts/new` path.

```ruby
# app/controllers/posts_controller.rb

class PostsController < ApplicationController
  before_action :authenticate_user!, except: [:show, :index] # add this line
  ...

end

```

Adding this before action allows us to require a new session from any user going forward on all paths except for the `index` and `show` actions.

Go ahead and create a test account to bypass this then head to `localhost:3000/posts/new`.

### Enhancing the Post form

Our `Post` form is a partial inside the `app/views/posts` directory called `_form.html.erb`. I changed the markup to include some basic styling. It’s nothing fancy but beats no styles.

```erb
<!-- app/views/posts/_form.html.erb-->

<%= form_with(model: post, local: true) do |form| %>
  <% if post.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(post.errors.count, "error") %> prohibited this post from being saved:</h2>

      <ul>
        <% post.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="mb-6">
    <%= form.label :title, class: "label" %>
    <%= form.text_field :title, class: "input" %>
  </div>

  <div class="mb-6">
    <%= form.label :body, class: "label" %>
    <%= form.text_area :body, class: "input" %>
  </div>

  <div class="mb-6">
    <%= form.submit class: "btn-default btn" %>
  </div>
<% end %>

```

I removed the reference to `user_id` here since we’ll assign it behind the scenes in the controller (another reason we require the user to be authenticated before hitting `/posts/new`). Here is the current state of that file after the update. I deleted all the comments for clarity sake.

```ruby
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  before_action :authenticate_user!, except: [:show, :index]
  before_action :set_post, only: [:show, :edit, :update, :destroy]

  def index
    @posts = Post.all
  end

  def show
  end

  def new
    @post = Post.new
  end

  def edit
  end

  def create
    @post = Post.new(post_params)
    @post.user_id = current_user.id

    respond_to do |format|
      if @post.save
        format.html { redirect_to @post, notice: 'Post was successfully created.' }
        format.json { render :show, status: :created, location: @post }
      else
        format.html { render :new }
        format.json { render json: @post.errors, status: :unprocessable_entity }
      end
    end
  end

  def update
    respond_to do |format|
      if @post.update(post_params)
        format.html { redirect_to @post, notice: 'Post was successfully updated.' }
        format.json { render :show, status: :ok, location: @post }
      else
        format.html { render :edit }
        format.json { render json: @post.errors, status: :unprocessable_entity }
      end
    end
  end

  def destroy
    @post.destroy
    respond_to do |format|
      format.html { redirect_to posts_url, notice: 'Post was successfully destroyed.' }
      format.json { head :no_content }
    end
  end

  private

    def set_post
      @post = Post.find(params[:id])
    end

    def post_params
      params.require(:post).permit(:title, :body, :user_id)
    end
end

```

### Adding attachments

If you recall, I scaffolded the Post resource with no mention of an image or file upload. This was on purpose. With Active Storage you no longer need these columns directly on the database table. It will be housed within its own table for reference later. This all happens inside the model.

Let’s add a reference to attachments in the `Post` model.

```ruby
# app/models/post.rb

class Post < ApplicationRecord
  belongs_to :user
  has_one_attached :feature_image # add this line
end

```

Here I used a method relative to Active Storage called `has_one_attached`. There is also `has_many_attached` (for multiple uploads). You can name this whatever you please. I chose `feature_image` for the `Post` since it’s common that a blog post might have one.

With this addition, all the hard work is done. We can extend our `posts_controller` and `_form.html.erb` partial to now reference the attachment.

```ruby
# app/controllers/posts_controller.rb

class PostsController < ApplicationController
...

 private

    def set_post
      @post = Post.find(params[:id])
    end

    def post_params
      params.require(:post).permit(:title, :body, :user_id, :feature_image)
    end
end

```

In the controller, we need to white-list the new field within the `post_params` private method.

```erb
<!-- app/views/posts/_form.html.erb-->
<%= form_with(model: post, local: true, multipart: true) do |form| %>
  <% if post.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(post.errors.count, "error") %> prohibited this post from being saved:</h2>

      <ul>
        <% post.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="mb-6">
    <%= form.label :title, class: "label" %>
    <%= form.text_field :title, class: "input" %>
  </div>

  <div class="mb-6">
    <%= form.label :body, class: "label" %>
    <%= form.text_area :body, class: "input" %>
  </div>

  <div class="mb-6">
    <%= form.label :feature_image, class: "label" %>
    <%= form.file_field :feature_image, class: "input" %>
  </div>

  <div class="mb-6">
    <%= form.submit class: "btn-default btn" %>
  </div>
<% end %>

```

We extend the form to include the new `:feature_image` field. Not that it’s a `file_field`. Since we are using files in the form now the form helper needs to be extended to be `multipart:true`.

That should get you some type of UI like this at the moment

![https://i.imgur.com/U8N9bP3.png](https://i.imgur.com/U8N9bP3.png%5D)

That feature image area looks pretty boring so let’s move on to getting drag and drop working.

I’ll modify our `file_field` markup to the following on the form

```erb
<div class="mb-6">
    <%= form.label :feature_image, class: "label" %>
    <div class="dropzone dropzone-default dz-clickable" data-controller="dropzone" data-dropzone-max-file-size="2" data-dropzone-max-files="1">
    <%= form.file_field :feature_image, direct_upload: true, data: { target: 'dropzone.input' } %>
    <div class="dropzone-msg dz-message needsclick text-gray-600">
      <h3 class="dropzone-msg-title">Drag here to upload or click here to browse</h3>
      <span class="dropzone-msg-desc text-sm">2 MB file size maximum. Allowed file types png, jpg.</span>
      </div>
    </div>
  </div>

```

Surrounding the `file_field` is a `div` containing some data attributes for Stimulus.js to hook into. More data attributes are bound to the div which allow me to pass options through for the file amount and size via the front-end.

This is useful for reusing the same component later in other portion of a given application. Notice also all the `dropzone` classes. These are necessary for the dropzone dependency which we will integrate shortly. At the moment, there aren’t that many visual changes on the field. We still need the necessary JavaScript and a dash of CSS to help with that.

### Integrating the JavaScript

Inside our `app/javascript` directory is a folder called `controllers` which was created when we created the new app thanks to passing the `--webpack=stimulus` flag. This convention is common to the Stimulus.js library in that a controller is a component for which handles logic around a place in your views you declare. Naming conventions are crucial to Stimulus so there are a lot of those to get used to. Once you do, it’s not all that confusing.

All stimulus controllers require an element with the controller name passed. In our case, this would be like the one you might have seen surrounding the form file field.

```erb
<div class="dropzone dropzone-default dz-clickable" data-controller="dropzone" data-dropzone-max-file-size="2" data-dropzone-max-files="1">
  <%= form.file_field :feature_image %>
</div>

```

the `data-controller="dropzone"` is the one I’m referring to. This tells stimulus there is indeed a controller mounted and ready to receive instructions. We can write those instructions inside the `app/javascript/controllers` directory. Given that I named the controller `dropzone` we need to create a file called `dropzone_controller.js` in that folder.

![dropzone_controller file in file tree](https://i2.wp.com/web-crunch.com/wp-content/uploads/2019/09/Screen-Shot-2019-09-15-at-11.12.39-AM.png?resize=349%2C370&ssl=1)

Inside this file will be where all the magic happens. It’s worth noting that this file and all the others inside `app/javascript/controllers` get imported to the main `application.js` file inside `app/javascript/packs/application.js`.

```javascript
// app/javascript/packs/application.js

require("@rails/ujs").start()
require("turbolinks").start()
require("@rails/activestorage").start()
require("channels")

import "controllers"
import "stylesheets/application"

```

That happens inside `app/javascript/controllers/index.js` where each of the controllers gets imported thanks to those naming conventions. This file is also where Simulus.js gets imported.

```javascript
// app/javascript/controllers/index.js
// Load all the controllers within this directory and all subdirectories. 
// Controller files must be named *_controller.js.

import { Application } from "stimulus"
import { definitionsFromContext } from "stimulus/webpack-helpers"

const application = Application.start()
const context = require.context("controllers", true, /_controller\.js$/)
application.load(definitionsFromContext(context))

```

We already load `app/javascript/packs/application.js` in our main layout view file `app/views/layouts/application.html.erb` so there’s little configuration to do.

```erb
<!DOCTYPE html>
<html>
  <head>

   <!-- more code here-->

    <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>

  </head>

  <!-- more code here-->

```

### The meat and potatoes

To get started I created a handful of helper functions and imported those from another file. That will live inside the `app/javascript/` directory. Create a new folder called `helpers`. Inside it, add a file called `index.js` with the following code:

```javascript
// app/javascript/helpers/index.js

export function getMetaValue(name) {
  const element = findElement(document.head, `meta[name="${name}"]`);
  if (element) {
    return element.getAttribute("content");
  }
}

export function findElement(root, selector) {
  if (typeof root == "string") {
    selector = root;
    root = document;
  }
  return root.querySelector(selector);
}

export function toArray(value) {
  if (Array.isArray(value)) {
    return value;
  } else if (Array.from) {
    return Array.from(value);
  } else {
    return [].slice.call(value);
  }
}

export function removeElement(el) {
  if (el && el.parentNode) {
    el.parentNode.removeChild(el);
  }
}

export function insertAfter(el, referenceNode) {
  return referenceNode.parentNode.insertBefore(el, referenceNode.nextSibling);
}

```

Here I’m exporting each function so we can import those as needed elsewhere. This extracts some unnecessary logic from `dropzone_controller.js` and also makes it accessible to other future javascript work should we require it.

Finally, in the dropzone controller file, I added the following code.

```javascript
import Dropzone from "dropzone";
import { Controller } from "stimulus";
import { DirectUpload } from "@rails/activestorage";
import {
  getMetaValue,
  toArray,
  findElement,
  removeElement,
  insertAfter
} from "helpers";

export default class extends Controller {
  static targets = ["input"];

  connect() {
    this.dropZone = createDropZone(this);
    this.hideFileInput();
    this.bindEvents();
    Dropzone.autoDiscover = false; // necessary quirk for Dropzone error in console
  }

  // Private
  hideFileInput() {
    this.inputTarget.disabled = true;
    this.inputTarget.style.display = "none";
  }

  bindEvents() {
    this.dropZone.on("addedfile", file => {
      setTimeout(() => {
        file.accepted && createDirectUploadController(this, file).start();
      }, 500);
    });

    this.dropZone.on("removedfile", file => {
      file.controller && removeElement(file.controller.hiddenInput);
    });

    this.dropZone.on("canceled", file => {
      file.controller && file.controller.xhr.abort();
    });
  }

  get headers() {
    return { "X-CSRF-Token": getMetaValue("csrf-token") };
  }

  get url() {
    return this.inputTarget.getAttribute("data-direct-upload-url");
  }

  get maxFiles() {
    return this.data.get("maxFiles") || 1;
  }

  get maxFileSize() {
    return this.data.get("maxFileSize") || 256;
  }

  get acceptedFiles() {
    return this.data.get("acceptedFiles");
  }

  get addRemoveLinks() {
    return this.data.get("addRemoveLinks") || true;
  }
}

class DirectUploadController {
  constructor(source, file) {
    this.directUpload = createDirectUpload(file, source.url, this);
    this.source = source;
    this.file = file;
  }

  start() {
    this.file.controller = this;
    this.hiddenInput = this.createHiddenInput();
    this.directUpload.create((error, attributes) => {
      if (error) {
        removeElement(this.hiddenInput);
        this.emitDropzoneError(error);
      } else {
        this.hiddenInput.value = attributes.signed_id;
        this.emitDropzoneSuccess();
      }
    });
  }

  createHiddenInput() {
    const input = document.createElement("input");
    input.type = "hidden";
    input.name = this.source.inputTarget.name;
    insertAfter(input, this.source.inputTarget);
    return input;
  }

  directUploadWillStoreFileWithXHR(xhr) {
    this.bindProgressEvent(xhr);
    this.emitDropzoneUploading();
  }

  bindProgressEvent(xhr) {
    this.xhr = xhr;
    this.xhr.upload.addEventListener("progress", event =>
      this.uploadRequestDidProgress(event)
    );
  }

  uploadRequestDidProgress(event) {
    const element = this.source.element;
    const progress = (event.loaded / event.total) * 100;
    findElement(
      this.file.previewTemplate,
      ".dz-upload"
    ).style.width = `${progress}%`;
  }

  emitDropzoneUploading() {
    this.file.status = Dropzone.UPLOADING;
    this.source.dropZone.emit("processing", this.file);
  }

  emitDropzoneError(error) {
    this.file.status = Dropzone.ERROR;
    this.source.dropZone.emit("error", this.file, error);
    this.source.dropZone.emit("complete", this.file);
  }

  emitDropzoneSuccess() {
    this.file.status = Dropzone.SUCCESS;
    this.source.dropZone.emit("success", this.file);
    this.source.dropZone.emit("complete", this.file);
  }
}

function createDirectUploadController(source, file) {
  return new DirectUploadController(source, file);
}

function createDirectUpload(file, url, controller) {
  return new DirectUpload(file, url, controller);
}

function createDropZone(controller) {
  return new Dropzone(controller.element, {
    url: controller.url,
    headers: controller.headers,
    maxFiles: controller.maxFiles,
    maxFilesize: controller.maxFileSize,
    acceptedFiles: controller.acceptedFiles,
    addRemoveLinks: controller.addRemoveLinks,
    autoQueue: false
  });
}

```

There is a lot going on here as you can see. Much of the logic deals with event listening and getting values from the front-end to pass to our dropzone instance. We hook into both dropzone and active storage to make the uploads work as advertised. I import those helpers I mentioned prior and reference them here.

This gets us close but our drag and drop zone doesn’t look the part. I leverage Tailwind CSS already for the application styles. We can import the defaults from Dropzone directly thanks to modern Webpack and JavaScript. Doing so takes place in my pre-existing `_forms.scss` partial.

```css
/* app/javascript/stylesheets/components/_form.scss */

@import "dropzone/dist/min/dropzone.min.css";
@import "dropzone/dist/min/basic.min.css";

.input {
  @apply appearance-none block w-full bg-gray-100 text-gray-700 border border-gray-300 rounded py-3 px-4 leading-tight;
}

.input:focus {
  @apply outline-none bg-white border-gray-400;
}

.label {
  @apply block text-gray-700 text-sm font-bold mb-2;
}

.select {
  @apply appearance-none py-3 px-4 pr-8 block w-full bg-gray-100 border border-gray-300 text-gray-700
   rounded leading-tight;
  -webkit-appearance: none;
}

.select:focus {
  @apply outline-none bg-white border-gray-400;
}

.dropzone {
  @apply border-2 rounded-lg border-gray-400 border-dashed;

  &.dz-drag-hover {
    @apply border-2 rounded-lg border-gray-200 border-dashed;

    .dz-message {
      opacity: 0.9;
    }
  }
}

```

Pay the most attention to the `@import` statements at the beginning of the file and the `.dropzone` class at the end. We extend it a bit with some Tailwind specific CSS to get the UI to look like the following:

![dropzone user interface complete](https://i2.wp.com/web-crunch.com/wp-content/uploads/2019/09/Screen-Shot-2019-09-15-at-11.39.56-AM.png?resize=784%2C643&ssl=1)

It looks a lot more like a drag and drop uploader now huh? Sweet. If all goes correctly this should be in working order.

Looks like it worked for me! If you ran into errors it could be due to image size/type of which we passed through on the form element itself with data-attributes. You can adjust those accordingly for your needs.

![drag and drop functionality works](https://i1.wp.com/web-crunch.com/wp-content/uploads/2019/09/Screen-Shot-2019-09-15-at-11.41.43-AM.png?resize=798%2C670&ssl=1)

### Displaying the upload

In a production app, you probably want to configure where your uploads via Active Storage get stored. You can do that quite easily in `config/storage.yml`. There are loads of options to choose from in terms of storage providers. You can pass your API keys through and be done.

For the purposes of this tutorial, we are leveraging local system storage. It works fine for development.

To see our work we still need to display the upload on the `index` and `show` views. I’ll update those to reflect.

```erb
<!-- app/views/posts/index.html.erb-->

<div class="max-w-6xl m-auto">
  <div class="flex items-center justify-between pb-4 mb-4 border-b">
    <h1 class="text-xl font-bold mb-0 uppercase text-gray-500">Posts</h1>
    <%= link_to "New Post", new_post_path, class: "btn btn-default" if user_signed_in? %>
  </div>

<% @posts.each do |post| %>
  <article class="border rounded-lg lg:w-1/3 w-full">
    <%= link_to post do %>
      <%= image_tag post.feature_image if post.feature_image.present? %>
    <% end %>

    <div class="p-6">
      <h1 class="text-2xl font-bold"><%= link_to post.title, post %></h1>

      <div class="leading-normal text-lg">
        <%= post.body %>
      </div>

      <% if user_signed_in? && current_user.id == post.id %>
        <div class="my-4">
          <%= link_to 'Edit', edit_post_path(post), class: "btn btn-default" %>
          <%= link_to 'Delete', post, method: :delete, data: { confirm: 'Are you sure?' }, class: "btn btn-default" %>
      </div>
      <% end %>
    <% end %>
  </div>
</div>

```

And finally the `show` view

```erb
<div class="max-w-4xl m-auto">
  <%= link_to @post do %>
    <%= image_tag @post.feature_image if @post.feature_image.present? %>
  <% end %>

  <div class="p-6">
    <h1 class="text-2xl font-bold"><%= link_to @post.title, @post %></h1>

    <div class="leading-normal text-lg">
      <%= @post.body %>
    </div>

    <% if user_signed_in? && current_user.id == @post.id %>
      <div class="my-4">
        <%= link_to 'Edit', edit_post_path(@post), class: "btn btn-default" %>
        <%= link_to 'Delete', @post, method: :delete, data: { confirm: 'Are you sure?' }, class: "btn btn-default" %>
    </div>
    <% end %>
  </div>
</div>

```

I went ahead and updated the routing to default to `/posts` as the root path. This is done like so:

```ruby
# config/routes.rb

require 'sidekiq/web'

Rails.application.routes.draw do
  resources :posts
    authenticate :user, lambda { |u| u.admin? } do
      mount Sidekiq::Web => '/sidekiq'
    end

  devise_for :users
  root to: 'posts#index' # change to `posts#index`
end

```

### Closing

There you have it! While it was a little bit of work, we now have drag and drop uploads with a reusable Stimulus.js component. We leveraged Dropzone.js and Active Storage direct upload to trigger uploads in the browser with JavaScript all while leveraging the core aspects of Ruby on Rails we all know and love. I hope you learned something from this guide. Feel free to tweak the code to match your own needs.

### Shameless plug time

[![](https://i1.wp.com/web-crunch.com/wp-content/uploads/2019/01/YouTube-Promo3.jpg?resize=3840%2C2160&ssl=1)](http://hellorails.io)

I have a new course called [Hello Rails](https://hellorails.io). Hello Rails is a modern course designed to help you start using and understanding Ruby on Rails fast. If you’re a novice when it comes to Ruby or Ruby on Rails I invite you to check out the site. The course will be much like these builds but a super more in-depth version with more realistic goals and deliverables. [Get Hello Rails today!](https://hellorails.io/)!

Follow [@hello\_rails](https://twitter.com/hello_rails) and [myself @justalever](https://twitter.com/justalever) on Twitter.

The post [Ruby on Rails Drag and Drop Uploads with Active Storage, Stimulus.js and Dropzone.js](https://web-crunch.com/rails-drag-drop-active-storage-stimulus-dropzone/) appeared first on [Web-Crunch](https://web-crunch.com).