Create a Client-Side Drag and Drop in Phoenix LiveView


Phoenix LiveView server-side rendering is very fast.

However, there are situations where client-side implementation results in a better user experience.

In this tutorial, we'll add SortableJS to a Phoenix LiveView project to implement a drag and drop interaction. I'll show you how you can make client-side drag and drop event data available to a server-side Phoenix LiveView using hooks.

What this project will look like

animated example of drag and drop project

Video walkthrough

Learn better with video? In this video below, you can learn how to build this project and hear Kelsey discuss the purpose behind each step.

Source code

You can also grab the source code for this demo below if you want to follow along.

See Source Code

How to get started

Create Phoenix LiveView project

Create a Phoenix LiveView project by running the following command in your terminal:

   -- CODE line-numbers language-bash --

   <!--

     $ mix phx.new draggable_walkthru --live

   -->

Learn more about Elixir Mix

You may need to update `phoenix_live_view` and `phoenix_live_dashboard` in `mix.exs`:

   -- CODE line-numbers language-bash --

   <!--

     {:phoenix_live_view, "~> 0.13.0"},

     {:phoenix_live_dashboard, "~> 0.2.0"},

   -->

Remove boilerplate content from `header` tag in `root.html.leex` and replace it with an `h1` tag:

   -- CODE line-numbers language-html --

   <!--

     <!DOCTYPE html>

     <html lang="en">

       <head>

         <meta charset="utf-8"/>

         <meta http-equiv="X-UA-Compatible" content="IE=edge"/>

         <meta name="viewport" content="width=device-width, initial-scale=1.0"/>

         <%= csrf_meta_tag() %>

         <%= live_title_tag assigns[:page_title] || "DraggableWalkthru", suffix: " · Phoenix Framework" %>

         <link rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>

         <script defer type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>

       </head>

       <body>

         <header>

           <div class="container">

             <h1>Draggable</h1>

           </div>

         </header>

         <%= @inner_content %>

       </body>

     </html>

   -->

Delete everything from `page_live.html.leex` but do not delete the file.

When you run `mix phx.server` you should see the following:

initial setup of phoenix liveview server
Note:

Don't forget to run `mix ecto.create` before you run `mix phx.server`.

If you're following along and don't need an Ecto database, you can add `--no-ecto` when creating your project (`mix phx.new draggable_walkthru --live --no-ecto`).

If you add the `--no-ecto` flag, you won't need to run `mix ecto.create`.


Add Tailwind CSS and JavaScript dependencies

In this example, I'm using Tailwind CSS for quick utility-based styling. Because this is a simple project, I'm opting to use the CDN for Tailwind.

Add the CDN link tag to `root.html.leex` before the link tag for `app.css`:

   -- CODE line-numbers language-html --

   <!--

     <link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">

     <link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>

   -->

We'll use SortableJS to implement drag-and-drop. Add SortableJS to your project by running the following command in your terminal:

   -- CODE line-numbers language-bash --

   <!--

     npm i sortablejs --prefix assets

   -->

Setup PageLive and create LiveComponent

In `page_live.ex`, remove both `handle_event` functions and the `search` function. Since we removed the default content from `page_live.html.leex`, we don't need these anymore.

Remove the call to assign from the `mount` function.

Your `page_live.ex` file should look like this:

   -- CODE line-numbers language-elixir --

   <!--

     defmodule DraggableWalkthruWeb.PageLive do

       use DraggableWalkthruWeb, :live_view

       @impl true

       def mount(_params, _session, socket) do

         {:ok, socket}

       end

     end

   -->

Within mount we'll add some mock data. In a real app this data would come from a datasource.

   -- CODE line-numbers language-elixir --

   <!--

     @impl true

       def mount(_params, _session, socket) do

         # this is hardcoded but would come from a datasource

         draggables = [

           %{id: "drag-me-0", text: "Drag Me 0"},

           %{id: "drag-me-1", text: "Drag Me 1"},

           %{id: "drag-me-2", text: "Drag Me 2"},

           %{id: "drag-me-3", text: "Drag Me 3"},

         ]

         {:ok, socket}

       end

   -->

Next we'll add three assigns to socket:

`pool` - draggable items that haven't been assigned to a drop zone

`drop_zone_a` - our first drop zone

`drop_zone_b` - our second drop zone

In mount, all the elements in `draggables` will be allocated to `pool` and our drop zones will be initialized with empty lists:

   -- CODE line-numbers language-elixir --

   <!--

     @impl true

       def mount(_params, _session, socket) do

         # this is hardcoded but would come from a datasource

         draggables = [

           %{id: "drag-me-0", text: "Drag Me 0"},

           %{id: "drag-me-1", text: "Drag Me 1"},

           %{id: "drag-me-2", text: "Drag Me 2"},

           %{id: "drag-me-3", text: "Drag Me 3"},

         ]

         socket =

           socket

           |> assign(:pool, draggables)

           |> assign(:drop_zone_a, [])

           |> assign(:drop_zone_b, [])

         {:ok, socket}

       end

   -->

Next we'll create a LiveComponent for our drop zones. Within `lib/draggable_walkthru_web/live` create a file named `drop_zone_component.ex`.

Add a `mount` function and a `render` function. Mount will return `{:ok, socket}`.

Within render, we'll add Live Eex (the multiline string preceded by `~L`) to define how we want our component rendered.

   -- CODE line-numbers language-elixir --

   <!--

     defmodule DraggableWalkthruWeb.PageLive.DropZoneComponent do

       use Phoenix.LiveComponent

       @impl true

       def mount(socket) do

         {:ok, socket}

       end

       @impl true

       def render(assigns) do

         ~L"""

           <div class="dropzone grid gap-3 p-6 border-solid border-2 border-<%= @color %>-300 rounded-md my-6" id="<%= @drop_zone_id %>">

             <%= @title %>

             <%= for %{text: text, id: id} <- @draggables do %>

               <div draggable="true" id="<%= id %>" class="draggable p-4 bg-<%= @color %>-700 text-white"><%= text %></div>

             <% end %>

           </div>

         """

       end

     end

   -->

Note

We've used Tailwind CSS classes to style the elements in our component render. To learn more about these utility classes review the Tailwind CSS documentation!

In our drop zone LiveComponent we'll pass through four assigns:

`draggables` - the list of items currently in the drop zone. The list is iterated over and a div is rendered for each item.

`drop_zone_id` - a value for the root div's `id` attribute (we'll need this for drag-and-drop later)

`title` - text to display at the top of the drop zone

`color` - the background color for the drop zone and the items within it

Note

Note we add `draggable="true"` to our item div to indicate the element can be dragged. Learn more about the HTML global attribute `draggable` within the MDN Web Docs.


Now that we have a drop zone component, we can use it within `page_live.html.leex`:

   -- CODE line-numbers language-elixir --

   <!--

     <%= live_component @socket, DraggableWalkthruWeb.PageLive.DropZoneComponent,

         draggables: @drop_zone_a,

         drop_zone_id: "drop_zone_a",

         title: "Drop Zone A",

         color: "orange"

     %>

     <%= live_component @socket, DraggableWalkthruWeb.PageLive.DropZoneComponent,

         draggables: @drop_zone_b,

         drop_zone_id: "drop_zone_b",

         title: "Drop Zone B",

         color: "green"

     %>

   -->

Now our app will have two empty drop zones with their own titles and colors.

two empty drop zones in yellow and green

Note

Learn more about Phoenix LiveComponents in the Phoenix LiveView documentation!

We'll add our pool directly inside `page_live.html.leex` above our drop zones. We'll iterate over the `pool` assign and render a draggable div for each item in the `pool` list.

   -- CODE line-numbers language-elixir --

   <!--

     <div class="dropzone grid gap-3" id="pool">

         <%= for %{text: text, id: id} <- @pool do %>

           <div draggable="true" id="<%= id %>" class="draggable p-4 bg-blue-700 text-white"><%= text %></div>

         <% end %>

     </div>

     <%= live_component @socket, DraggableWalkthruWeb.PageLive.DropZoneComponent,

         draggables: @drop_zone_a,

         drop_zone_id: "drop_zone_a",

         title: "Drop Zone A",

         color: "orange"

     %>

     <%= live_component @socket, DraggableWalkthruWeb.PageLive.DropZoneComponent,

         draggables: @drop_zone_b,

         drop_zone_id: "drop_zone_b",

         title: "Drop Zone B",

         color: "green"

     %>

   -->

Now our app looks like this. We can drag the list items but we can't move them out of the pool and we can't rearrange them.

screen recording that shows how you can drag list items

Add the Drag hook

Now that we have our markup in LiveEEx, we can add the client-side javascript to implement drag and drop functionality. We're adding this on the client-side because the user experience is smoother than implementing it server-side within our `page_live.ex`.

First, create a file within `assets/js` named `dragHook.js`. We'll import `sortablejs` at the top and add an object that is our default export:

   -- CODE line-numbers language-jsx --

   <!--

     import Sortable from 'sortablejs';

     export default {};

   -->

Client hooks can implement a number of lifecycle callbacks. In this hook, we only need to implement `mounted`. This callback is called when the element we add this hook to has been mounted to the DOM and the server-side LiveView has also mounted.

   -- CODE line-numbers language-jsx --

   <!--

     import Sortable from 'sortablejs';

     export default {

       mounted() {

         /* implementation will go here */

       }

     };

   -->

Note

Read more about JavaScript interoperability in the Phoenix LiveView documentation.

At the beginning of our `mounted` lifecycle callback function we'll define three variables:

`dragged` - the item currently being dragged

`hook` - a reference to `this` that we'll use later

`selector` - our element's id as a string prepended with "#"

   -- CODE line-numbers language-jsx --

   <!--

     export default {

       mounted() {

         let dragged; // this will change so we use `let`

         const hook = this;

         const selector = '#' + this.el.id;

       }

     }

   -->

Right away you'll see `this` has an `el` member. The callback lifecycle functions have several attributes in scope including `el`, a reference to the DOM element the hook has been added to.

Before we move on, let's add the hook to our DOM element so we can test to see if the `el` and it's ID are being passed through correctly.

First, we'll import our hook within `app.js` and add it to our LiveSocket. We'll create a `Hooks` object and add it to our SocketOptions within `new LiveSocket()`.

   -- CODE line-numbers language-jsx --

   <!--

     // assets/js/app.js

     // other imports omitted for clarity

     import Drag from './dragHook';

     const Hooks = { Drag: Drag }; // define an object to contain our hooks, our first key is `Drag`

     let csrfToken = document

       .querySelector("meta[name='csrf-token']")

       .getAttribute('content');

     let liveSocket = new LiveSocket('/live', Socket, {

       params: { _csrf_token: csrfToken },

       hooks: Hooks, // add hooks to the SocketOptions object

     });

   -->

Next, within `page_live.html.leex`, we'll surround our markup with a `div` that has a `phx-hook` attribute and an `id` attribute. The value for `phx-hook` should match the key we used in our `Hooks` object and when using `phx-hook` we have to define a unique DOM id as well.

   -- CODE line-numbers language-html --

   <!--

     <div phx-hook="Drag" id="drag">

         <div class="dropzone grid gap-3" id="pool">

             <%= for %{text: text, id: id} <- @pool do %>

             <div draggable="true" id="<%= id %>" class="draggable p-4 bg-blue-700 text-white"><%= text %></div>

             <% end %>

         </div>

         <%= live_component @socket, DraggableWalkthruWeb.PageLive.DropZoneComponent,

             draggables: @drop_zone_a,

             drop_zone_id: "drop_zone_a",

             title: "Drop Zone A",

             color: "orange"

         %>

         <%= live_component @socket, DraggableWalkthruWeb.PageLive.DropZoneComponent,

             draggables: @drop_zone_b,

             drop_zone_id: "drop_zone_b",

             title: "Drop Zone B",

             color: "green"

         %>

     </div>

   -->

Now if we add a `console.log` after we define `selector` in `mounted`, we should see our message logged in the console:

   -- CODE line-numbers language-jsx --

   <!--

     mounted() {

       let dragged;

       const hook = this;

       const selector = '#' + this.el.id;

       console.log('The selector is:', selector);

     }

   -->

draghook set up verification

Great! We have a hook and it's linking our LiveEEx markup with our JavaScript. Next, we'll start adding the drag and drop functionality.

Implement drag and drop using SortableJS

Within `dragHook.js` we want to make the "pool", "drop zone A", and "drop zone B" sortable and we want to be able to drag items from one to any of the others.

Within mounted, we'll start by getting references to all dropzone elements using `document.querySelectorAll` and the "dropzone" class as the selector:

   -- CODE line-numbers language-jsx --

   <!--

     import Sortable from 'sortablejs';

     export default {

       mounted() {

         let dragged;

         const hook = this;

         const selector = '#' + this.el.id;

         // make sure you prepend the class's name with a period "." to indicate it's a class

         document.querySelectorAll('.dropzone').forEach((dropzone) => {

           /* implementation to make this sortable will go here */

         });

       },

     };

   -->

Note

In our markup, we've already added the class "dropzone" to our pool and drop zones "A" and "B"!

We'll iterate over the elements returned by `querySelectorAll` using `forEach`.

With each dropzone element, we'll instantiate a `Sortable` by calling `new Sortable` and passing in the element `dropzone` as the first parameter and an object of Sortable options as our second parameter.

   -- CODE line-numbers language-jsx --

   <!--

     import Sortable from 'sortablejs';

     export default {

       mounted() {

         let dragged;

         const hook = this;

         const selector = '#' + this.el.id;

         document.querySelectorAll('.dropzone').forEach((dropzone) => {

           new Sortable(dropzone, {

             animation: 0,

             delay: 50,

             delayOnTouchOnly: true,

             group: 'shared',

             draggable: '.draggable',

             ghostClass: 'sortable-ghost'

           });

         });

       },

     };

   -->

A detailed review of all the available options is outside the scope of this article, but we'll briefly review what options we've utilized here:

`animation` - the number of milliseconds to animate

`delay` - the number of milliseconds to delay

`delayOnTouchOnly` - we only want to delay if the interaction is via touch (not mouse or keyboard) so mobile users can scroll without grabbing draggable items

`group` - this is a string identifier that is shared by our pool and drop zones "A" and "B". It tells sortable that draggable items can be dragged from one to the others since they share the same string identifier

`draggable` - the selector Sortable will use to identify which child elements are draggable

`ghostClass` - the class that should be used to style the drop placeholder

Since the `draggable` class is used as a selector only, we don't need to add CSS.

We've added the `ghostClass` of `.sortable-ghost` to the bottom of `assets/css/app.scss`:

   -- CODE line-numbers language-scss --

   <!--

     .sortable-ghost {

       opacity: 0.75; // make the drop placeholder slightly transparent

     }

   -->

Now that we've added our `Sortable` instantiation, we can drag the items from the pool into dropzones "A" and "B"!

screen recording of drag and drop sorting between drop zones

The drag and drop user experience is working!

Unfortunately though, our Elixir app doesn't know when an element has been moved nor where it has been moved to. Let's add that next!

Push and handle hook events

When we drop a draggable element into a drop zone we want to make that information available to our PageLive module (`page_live.ex`).

In a real app, we might want to make a database update or publish the information using PubSub when an item is dropped into a drop zone.

Before we try to pass that information from our JavaScript implementation, let's add an event handler to the PageLive module:

   -- CODE line-numbers language-elixir --

   <!--

     defmodule DraggableWalkthruWeb.PageLive do

       use DraggableWalkthruWeb, :live_view

       @impl true

       def mount(_params, _session, socket) do

         # this is hardcoded but would come from a datasource

         draggables = [

           %{id: "drag-me-0", text: "Drag Me 0"},

           %{id: "drag-me-1", text: "Drag Me 1"},

           %{id: "drag-me-2", text: "Drag Me 2"},

           %{id: "drag-me-3", text: "Drag Me 3"},

         ]

         socket =

           socket

           |> assign(:pool, draggables)

           |> assign(:drop_zone_a, [])

           |> assign(:drop_zone_b, [])

         {:ok, socket}

       end

       @impl true

       def handle_event("dropped", params, socket) do

         # implementation will go here

         {:noreply, socket}

       end

     end

   -->

Our `handle_event` callback function accepts as it's params:

  • an event - here we are identifying the event with the string "dropped"
  • parameters - parameters passed with the event
  • the socket

We'll push the event from the Drag hook. We'll add another option, `onEnd`, to the Sortable options:

   -- CODE line-numbers language-jsx --

   <!--

     import Sortable from 'sortablejs';

     export default {

       mounted() {

         let dragged;

         const hook = this;

         const selector = '#' + this.el.id;

         document.querySelectorAll('.dropzone').forEach((dropzone) => {

           new Sortable(dropzone, {

             animation: 0,

             delay: 50,

             delayOnTouchOnly: true,

             group: 'shared',

             draggable: '.draggable',

             ghostClass: 'sortable-ghost',

             onEnd: function (evt) {

               /* implementation goes here */

             },

           });

         });

       },

     };

   -->

Within `onEnd`, we'll call `hook.pushEventTo`.

We'll pass `selector` in as the first parameter.

For our second parameter we want make sure to pass the same string we used as the event parameter in `handle_event` so we'll pass in "dropped".

For our third parameter, we'll pass in a payload object of parameters for use by `handle_event`.

   -- CODE line-numbers language-jsx --

   <!--

     onEnd: function (evt) {

       hook.pushEventTo(selector, 'dropped', {

         draggedId: evt.item.id, // id of the dragged item

         dropzoneId: evt.to.id, // id of the drop zone where the drop occured

         draggableIndex: evt.newDraggableIndex, // index where the item was dropped (relative to other items in the drop zone)

       });

     },

   -->

Note

In our example, we use `pushEventTo`. This function pushes the event to the LiveView or LiveComponent specified by the selector. Hooks also have `pushEvent` if the event should be pushed to the LiveView server instead.

The parameters from the payload object are available within the second parameter of the `handle_event` callback function in `page_live.ex`. We can destructure the second parameter of the function definition to access the payload object members:

   -- CODE line-numbers language-elixir --

   <!--

     def handle_event("dropped", %{"draggedId" => dragged_id, "dropzoneId" => drop_zone_id,"draggableIndex" => draggable_index}, socket) do

       # implementation will go here

       {:noreply, socket}

     end

   -->

Now that we have the necessary data being passed from the client to the server, we can use it to update the `pool`, `drop_zone_a`, and `drop_zone_b` lists in the socket assigns.

Update socket assigns on drop

First, we'll identify which drop zone the drop occurred in. We'll get the correct atom for the event parameter `dropzoneId`. We search our drop zone atoms for one that matches the `dropzoneId`. We want to prevent the user from creating additional atoms and from corrupting our assigns.

   -- CODE line-numbers language-elixir --

   <!--

     def handle_event("dropped", %{"draggedId" => dragged_id, "dropzoneId" => drop_zone_id,"draggableIndex" => draggable_index}, %{assigns: %{pool: pool, drop_zone_a: drop_zone_a, drop_zone_b: drop_zone_b}} = socket) do

       drop_zone_atom =

           [:pool, :drop_zone_a, :drop_zone_b]

           |> Enum.find(fn zone_atom -> to_string(zone_atom) == drop_zone_id end)

       if drop_zone_atom === nil do

       throw "invalid drop_zone_id"

       end

       {:noreply, socket}

     end

   -->

Next, we'll get the dragged item using the event parameter `draggedId`. We'll add a private function called `find_dragged` that accepts the `socket`'s assigns as its first parameter and the `draggedId` as its second parameter.

The function will combine the `pool`, `drop_zone_a`, and `drop_zone_b` lists and use `Enum.find` to return the draggable with an id equal to `draggedId`. In `handle_event` we'll destructure `assigns` from the third parameter, `socket`.

   -- CODE line-numbers language-elixir --

   <!--

     def handle_event("dropped", %{"draggedId" => dragged_id, "dropzoneId" => drop_zone_id,"draggableIndex" => draggable_index}, %{assigns: assigns} = socket) do

       drop_zone_atom = drop_zone_id |> get_drop_zone_atom

       dragged = find_dragged(assigns, dragged_id)

       {:noreply, socket}

     end

     defp find_dragged(%{pool: pool, drop_zone_a: drop_zone_a, drop_zone_b: drop_zone_b}, dragged_id) do

       pool ++ drop_zone_a ++ drop_zone_b

         |> Enum.find(nil, fn draggable ->

           draggable.id == dragged_id

         end)

     end

   -->

Finally, we'll use `drop_zone_atom`, `dragged`, and the event parameter `draggableIndex` to update the `pool`, `drop_zone_a`, and `drop_zone_b` lists in `socket`'s assigns.

We'll reduce over the atoms for the lists using `Enum.reduce`.

We'll pass in the socket as the initial value for the accumulator and in the callback function, we'll destructure assigns from the current accumulator.

   -- CODE line-numbers language-elixir --

   <!--

     def handle_event("dropped", %{"draggedId" => dragged_id, "dropzoneId" => drop_zone_id,"draggableIndex" => draggable_index}, %{assigns: assigns} = socket) do

       drop_zone_atom = drop_zone_id |> get_drop_zone_atom

       dragged = find_dragged(assigns, dragged_id)

       socket =

         [:pool, :drop_zone_a, :drop_zone_b]

         |> Enum.reduce(socket, fn zone_atom, %{assigns: assigns} = accumulator ->

           # implementation goes here

         end)

       {:noreply, socket}

     end

   -->

Within the reduce callback function we'll update the list for the current atom. We'll write a private function `update_list` with two function signature: one for use when the list being updated is the list being dropped into and a second function for when the list being updated isn't the list being dropped into.

Within `update_list` we'll remove the dragged item by calling a private function `remove_dragged`. Within the function when the list being updated is the list being dropped into, we'll add the dragged item back into the list using `List.insert_at` and the `draggable_index` parameter from the event.

   -- CODE line-numbers language-elixir --

   <!--

     defp update_list(assigns, list_atom, dragged, drop_zone_atom, draggable_index) when list_atom == drop_zone_atom  do

       assigns[list_atom]

       |> remove_dragged(dragged.id)

       |> List.insert_at(draggable_index, dragged)

     end

     defp update_list(assigns, list_atom, dragged, drop_zone_atom, draggable_index) when list_atom != drop_zone_atom  do

       assigns[list_atom]

       |> remove_dragged(dragged.id)

     end

     defp remove_dragged(list, dragged_id) do

       list

       |> Enum.filter(fn draggable ->

         draggable.id != dragged_id

       end)

     end

   -->

Once we've called `update_list`, we'll call assign, passing in the `accumulator` socket, the zone atom, and the updated list. We'll assign the return from `Enum.reduce` to socket and return `{:noreply, socket}`.

   -- CODE line-numbers language-elixir --

   <!--

     def handle_event("dropped", %{"draggedId" => dragged_id, "dropzoneId" => drop_zone_id,"draggableIndex" => draggable_index}, %{assigns: assigns} = socket) do

       drop_zone_atom = drop_zone_id |> get_drop_zone_atom

       dragged = find_dragged(assigns, dragged_id)

       socket = # assign to socket

         [:pool, :drop_zone_a, :drop_zone_b]

         |> Enum.reduce(socket, fn zone_atom, %{assigns: assigns} = accumulator ->

           updated_list =

             assigns

             |> update_list(zone_atom, dragged, drop_zone_atom, draggable_index)

           accumulator

             |> assign(zone_atom, updated_list)

         end)

       {:noreply, socket}

     end

   -->

The PageLive module (`page_live.ex`) will look like this when we're finished:

   -- CODE line-numbers language-elixir --

   <!--

     defmodule DraggableWalkthruWeb.PageLive do

       use DraggableWalkthruWeb, :live_view

       @impl true

       def mount(_params, _session, socket) do

         # this is hardcoded but would come from a datasource

         draggables = [

           %{id: "drag-me-0", text: "Drag Me 0"},

           %{id: "drag-me-1", text: "Drag Me 1"},

           %{id: "drag-me-2", text: "Drag Me 2"},

           %{id: "drag-me-3", text: "Drag Me 3"},

         ]

         socket =

           socket

           |> assign(:pool, draggables)

           |> assign(:drop_zone_a, [])

           |> assign(:drop_zone_b, [])

         {:ok, socket}

       end

       def handle_event("dropped", %{"draggedId" => dragged_id, "dropzoneId" => drop_zone_id,"draggableIndex" => draggable_index}, %{assigns: assigns} = socket) do

         drop_zone_atom = drop_zone_id |> get_drop_zone_atom

         dragged = find_dragged(assigns, dragged_id)

         socket =

           [:pool, :drop_zone_a, :drop_zone_b]

           |> Enum.reduce(socket, fn zone_atom, %{assigns: assigns} = accumulator ->

             updated_list =

               assigns

               |> update_list(zone_atom, dragged, drop_zone_atom, draggable_index)

             accumulator

               |> assign(zone_atom, updated_list)

           end)

         {:noreply, socket}

       end

       defp get_drop_zone_atom(drop_zone_id) do

         case drop_zone_id in ["pool", "drop_zone_a", "drop_zone_b"] do

           true ->

             drop_zone_id |> String.to_existing_atom()

           false ->

             throw "invalid drop_zone_id"

         end

       end

       defp find_dragged(%{pool: pool, drop_zone_a: drop_zone_a, drop_zone_b: drop_zone_b}, dragged_id) do

         pool ++ drop_zone_a ++ drop_zone_b

           |> Enum.find(nil, fn draggable ->

             draggable.id == dragged_id

           end)

       end

       def update_list(assigns, list_atom, dragged, drop_zone_atom, draggable_index) when list_atom == drop_zone_atom  do

         assigns[list_atom]

         |> remove_dragged(dragged.id)

         |> List.insert_at(draggable_index, dragged)

       end

       def update_list(assigns, list_atom, dragged, drop_zone_atom, draggable_index) when list_atom != drop_zone_atom  do

         assigns[list_atom]

         |> remove_dragged(dragged.id)

       end

       def remove_dragged(list, dragged_id) do

         list

         |> Enum.filter(fn draggable ->

           draggable.id != dragged_id

         end)

       end

     end

   -->

Now we can drag items into the various drop zones and we know the data is reaching the back end because the colors of the dropped items change!


screen recording of list items dragged into drop zones and colors changing

The colors change because in our DropZoneComponent (`drop_zone_component.ex`) we use the color assign passed in as the background color of the rendered items.

When the assigns in the PageLive module (`page_live.ex`) are updated, those updates flow into our DropZoneComponent and the list items are rendered with the color assign.

   -- CODE line-numbers language-elixir --

   <!--

     @impl true

     def render(assigns) do

       ~L"""

         <div class="dropzone grid gap-3 p-6 border-solid border-2 border-<%= @color %>-300 rounded-md my-6" id="<%= @drop_zone_id %>">

           <%= @title %>

           <%= for %{text: text, id: id} <- @draggables do %>

             <div draggable="true" id="<%= id %>" class="draggable p-4 bg-<%= @color %>-700 text-white"><%= text %></div>

           <% end %>

         </div>

       """

     end

   -->

Next steps

To learn more, check out the resources listed below. 👇🏻

Source code

You can grab the source code for this demo here:

Phoenix LiveView Hook Demo


Phoenix LiveView resources

Another approach: LiveView Trello clone by Pedro Assunção

Source code for Trello Clone by Pedro

JavaScript for Trello Clone by Pedro

Ideas we're building with Phoenix LiveView

Check out Birdseye, a tool to help you manage tasks from multiple sources in one simple view.