tutorial // Jun 10, 2022
How to Build a Drag and Drop UI with SortableJS
How to build a simple drag-and-drop shopping cart UI with a list of items and a cart to drop them into.

Getting Started
For this tutorial, we're going to use CheatCode's full-stack JavaScript framework, Joystick. Joystick brings together a front-end UI framework with a Node.js back-end for building apps.
To begin, we'll want to install Joystick via NPM. Make sure you're using Node.js 16+ before installing to ensure compatibility (give this tutorial a read first if you need to learn how to install Node.js or run multiple versions on your computer):
Terminal
npm i -g @joystick.js/cli
This will install Joystick globally on your computer. Once installed, next, let's create a fresh project:
Terminal
joystick create app
After a few seconds, you will see a message logged out to cd
into your new project and run joystick start
. Before you do that, we need to install one dependency sortablejs
:
Terminal
cd app && npm i sortablejs
After that, you can start up your app:
Terminal
joystick start
After this, your app should be running and we're ready to get started.
Adding a component for store items
To kick things off, we're going to jump ahead a little bit. In our store, our goal will be to have a list of items that can be dragged-and-dropped into a cart. To keep our UI consistent, we want to use the same design for the items in the store as we do in the cart.
To make this easy, let's start by creating a StoreItem
component that will display each of our cart items.
/ui/components/storeItem/index.js
import ui from '@joystick.js/ui';
const StoreItem = ui.component({
css: `
div {
position: relative;
width: 275px;
border: 1px solid #eee;
padding: 15px;
align-self: flex-end;
background: #fff;
box-shadow: 0px 0px 2px 2px rgba(0, 0, 0, 0.02);
}
div img {
max-width: 100%;
height: auto;
display: block;
}
div h2 {
font-size: 18px;
margin: 10px 0 0;
}
div p {
font-size: 15px;
line-height: 21px;
margin: 5px 0 0 0;
color: #888;
}
div button {
position: absolute;
top: 5px;
right: 5px;
z-index: 2;
}
`,
events: {
'click .remove-item': (event, component = {}) => {
if (component.props.onRemove) {
component.props.onRemove(component.props.item.id);
}
},
},
render: ({ props, when }) => {
return `
<div data-id="${props.item?.id}">
${when(props.onRemove, `<button class="remove-item">X</button>`)}
<img src="${props.item?.image}" alt="${props.item?.name}" />
<header>
<h2>${props.item?.name} — ${props.item?.price}</h2>
<p>${props.item?.description}</p>
</header>
</div>
`;
},
});
export default StoreItem;
Because this component is fairly simple, we've output the entire thing above.
Our goal here is to render a card-style design for each item. To start, down in the render()
function of the component above, we return a string of HTML which will represent the card when it's rendered on screen.
First, on the <div></div>
tag starting our HTML, we add a data-id
attribute set to the value props.item.id
. If we look at our render()
function definition we can see that we're expecting a value to be passed—an object representing the component instance—that we can destructure with JavaScript.
On that object, we expect a props
value which will contain the props or properties passed to our component as an object. On that object, we expect a prop item
which will contain the current item we're trying to render (either in the store or in the cart).
Here, the data-id
attribute that we're setting to props.item.id
will be utilized to identify which item is being added to the cart when it's dragged and dropped in our UI.
Next, we make use of Joystick's when()
function (known as a render function) which helps us to conditionally return some HTML based on a value. Here, we're passing props.onRemove
as the first argument (what we want to test for "truthiness") and, if it exists, we want to render a <button></button>
for removing the item. Because we're going to reuse this component for both our cart and our store items, we want to make the rendering of the remove button conditional as it only applies to items in our cart.
The rest of our HTML is quite simple. Using the same props.item
value, we render the image
, name
, price
, and description
from that object.
Up above this, in the events
object—where we define JavaScript event listeners for our component—we're defining an event listener which listens for a click
event on our <button></button>
's class .remove-item
. If a click is detected, Joystick will call the function we pass to click .remove-item
.
Inside of that function, we check to see if the component has a component.props.onRemove
value. If it does we want to call that function, passing in the component.props.item.id
, or, the ID of the item we're trying to remove from the cart.
Finally, at the top of our component, to make things look nice, we've added the necessary CSS to give our component a card-style appearance.
Moving on, next, we want to start getting the main Store
page wired up. Before we do, real quick we need to modify our routes on the server to render the store page we're going to create next.
Modifying the index route
We need to make a small change to the routes that were automatically added for us as part of our project template when we ran joystick create app
above. Opening up the /index.server.js
file at the root of the project, we want to change the name of the page that we're passing to res.render()
for the index /
route:
/index.server.js
import node from "@joystick.js/node";
import api from "./api";
node.app({
api,
routes: {
"/": (req, res) => {
res.render("ui/pages/store/index.js", {
layout: "ui/layouts/app/index.js",
});
},
"*": (req, res) => {
res.render("ui/pages/error/index.js", {
layout: "ui/layouts/app/index.js",
props: {
statusCode: 404,
},
});
},
},
});
Here, we want to modify the call to res.render()
inside of the handler function passed to the "/"
route, swapping the ui/pages/index/index.js
path for ui/pages/store/index.js
.
Note: this change is arbitrary and only for adding context to our work. If you wish, you can leave the original route intact and modify the page at /ui/pages/index/index.js
with the code we'll look at below.
Next, let's wire up the page with our store and cart where we'll implement our drag-and-drop UI at that path.
Adding a component for our store
Now for the important stuff. Let's start by creating the component we assumed would exist at /ui/pages/store/index.js
:
/ui/pages/store/index.js
import ui from '@joystick.js/ui';
import StoreItem from '../../components/storeItem';
const items = [
{ id: 'apple', image: '/apple.png', name: 'Apple', price: 0.75, description: 'The original forbidden fruit.' },
{ id: 'banana', image: '/banana.png', name: 'Banana', price: 1.15, description: 'It\'s long. It\'s yellow.' },
{ id: 'orange', image: '/orange.png', name: 'Orange', price: 0.95, description: 'The only fruit with a color named after it.' },
];
const Store = ui.component({
state: {
cart: [],
},
css: `
.store-items {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-column-gap: 20px;
list-style: none;
width: 50%;
padding: 40px;
margin: 0;
}
.cart {
display: flex;
background: #fff;
border-top: 1px solid #eee;
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 25px;
min-height: 150px;
text-align: center;
color: #888;
}
.cart footer {
position: absolute;
bottom: 100%;
right: 20px;
padding: 10px;
border: 1px solid #eee;
background: #fff;
}
.cart footer h2 {
margin: 0;
}
.cart-items {
width: 100%;
display: flex;
position: relative;
overflow-x: scroll;
}
.cart-items > div:not(.placeholder):not(:last-child) {
margin-right: 20px;
}
.cart-items .placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
`,
render: ({ component, each, when, state, methods }) => {
return `
<div class="store">
<div class="store-items">
${each(items, (item) => {
return component(StoreItem, { item });
})}
</div>
<div class="cart">
<div class="cart-items">
${when(state.cart.length === 0, `
<div class="placeholder">
<p>You don't have any items in your cart. Drag and drop items from above to add them to your cart.</p>
</div>
`)}
${each(state.cart, (item) => {
return component(StoreItem, {
item,
onRemove: (itemId) => {
// We'll handle removing the item here.
},
});
})}
</div>
<footer>
<h2>Total: ${/* We'll handle removing the item here. */}</h2>
</footer>
</div>
</div>
`;
},
});
export default Store;
Going from the top, first, we import the StoreItem
component that we create above. Just beneath this, we create a static list of items
as an array of objects, with each object representing one of the items available in our store. For each item, we have an id
, an image
, a name
, a price
, and a description
.
Just beneath this, we define our component using the ui.component()
function provided by the imported ui
object from @joystick.js/ui
at the top of the page. To it, we pass an options object describing our component. At the top of that, we kick things off by defining a default state
value for our component, adding an empty array for cart
(this is where our "dropped" items from the store will live).
This will allow us to start using state.cart
down in our render()
function without any items in it (if we didn't do this, we'd get an error at render time that state.cart
was undefined).
Just below this, we've added some css
for our store items and our cart. The outcome of this is a horizontal list for our store items and for our a cart, a "bin" fixed to the bottom of the screen where we can drag items.
The key part here is the render()
function. Here, we see a repeat of some of the patterns we learned about when building our StoreItem
component. Again, in our render()
, we return the HTML that we want to render for our component. Focusing on the details, we're leveraging an additional render function in addition to the when()
function we learned about earlier: each()
. Like the name implies, for each of x
items, we want to render some HTML.
Inside <div class="store-items"></div>
, we're calling to each()
passing the static items
list we created at the top of our file as the first argument and for the second, a function for each()
to call for each item in our array. This function is expected to return a string of HTML. Here, to get it, we return a call to another render function component()
which helps us to render another Joystick component inside of our HTML.
Here, we expect component()
to take our StoreItem
component (imported at the top of our file) and render it as HTML, passing the object we've passed as the second argument here as its props
value. Recall that earlier, we expect props.item
to be defined inside of StoreItem
—this is how we define it.
Below this, we render out our cart UI, utilizing when()
again to say "if our cart doesn't have any items in it, render a placeholder message to guide the user."
After this, we use each()
one more time, this time looping over our state.cart
value and again, returning a call to component()
and passing our StoreItem
component to it. Again, we pass item
as a prop and in addition to this, we pass the onRemove()
function we anticipated inside of StoreItem
that will render our "remove" button on our item.
Next, we have two placeholder comments to replace: what to do when onRemove()
is called and then, at the bottom of our render()
, providing a total for all of the items in our cart.
/ui/pages/store/index.js
import ui from '@joystick.js/ui';
import StoreItem from '../../components/storeItem';
const items = [
{ id: 'apple', image: '/apple.png', name: 'Apple', price: 0.75, description: 'The original forbidden fruit.' },
{ id: 'banana', image: '/banana.png', name: 'Banana', price: 1.15, description: 'It\'s long. It\'s yellow.' },
{ id: 'orange', image: '/orange.png', name: 'Orange', price: 0.95, description: 'The only fruit with a color named after it.' },
];
const Store = ui.component({
state: {
cart: [],
},
methods: {
getCartTotal: (component = {}) => {
const total = component?.state?.cart?.reduce((total = 0, item = {}) => {
return total += item.price;
}, 0);
return total?.toFixed(2);
},
handleRemoveItem: (itemId = '', component = {}) => {
component.setState({
cart: component?.state?.cart?.filter((cartItem) => {
return cartItem.id !== itemId;
}),
});
},
},
css: `...`,
render: ({ component, each, when, state, methods }) => {
return `
<div class="store">
<div class="store-items">
${each(items, (item) => {
return component(StoreItem, { item });
})}
</div>
<div class="cart">
<div class="cart-items">
${when(state.cart.length === 0, `
<div class="placeholder">
<p>You don't have any items in your cart. Drag and drop items from above to add them to your cart.</p>
</div>
`)}
${each(state.cart, (item) => {
return component(StoreItem, {
item,
onRemove: (itemId) => {
methods.handleRemoveItem(itemId);
},
});
})}
</div>
<footer>
<h2>Total: ${methods.getCartTotal()}</h2>
</footer>
</div>
</div>
`;
},
});
export default Store;
Making a slight change here, now, we're calling to methods.handleRemoveItem()
passing in the itemId
we expect to get back from StoreItem
when it calls the onRemove
function for an item. Down at the bottom, we've also added a call to methods.getCartTotal()
.
In a Joystick component, methods
are miscellaneous functions that we can call on our component. Up in the methods
object we've added, we're defining both of these functions.
For getCartTotal()
our goal is to loop over all of the items in state.cart
and provide a total for them. Here, to do it, we use a JavaScript reduce function to say "starting from 0
, for each item in state.cart
, return the current value of total
plus the value of the current item
's price
property.
For each iteration of .reduce()
the return value becomes the new value of total
which is then passed on to the next item in the array. When it's finished, reduce()
will return the final value.
Down in handleRemoveItem()
, our goal is to filter out any items our user wants to remove from state.cart
. To do it, we call to component.setState()
(Joystick automatically passed the component
instance as the final argument after any arguments we've passed to a method function), overwriting cart
with the result of calling to component.state.filter()
. For .filter()
we want to only keep the items with an id
that does not match the passed itemId
(i.e., filter it out of the cart).
With that, we're ready for the drag-and-drop. Let's see how it's wired up and then take our UI for a spin:
/ui/pages/store/index.js
import ui from '@joystick.js/ui';
import Sortable from 'sortablejs';
import StoreItem from '../../components/storeItem';
const items = [...];
const Store = ui.component({
state: {
cart: [],
},
lifecycle: {
onMount: (component = {}) => {
const storeItems = component.DOMNode.querySelector('.store-items');
const storeCart = component.DOMNode.querySelector('.cart-items');
component.itemsSortable = Sortable.create(storeItems, {
group: {
name: 'store',
pull: 'clone',
put: false,
},
sort: false,
});
component.cartSortable = Sortable.create(storeCart, {
group: {
name: 'store',
pull: true,
put: true,
},
sort: false,
onAdd: (event) => {
const target = event?.item?.querySelector('[data-id]');
const item = items?.find(({ id }) => id === target?.getAttribute('data-id'));
// NOTE: Remove the DOM node that SortableJS added for us before calling setState() to update
// our list. This prevents the render from breaking.
event?.item?.parentNode.removeChild(event.item);
component.setState({
cart: [...component.state.cart, {
...item,
id: `${item.id}-${component.state?.cart?.length + 1}`,
}],
});
},
});
},
},
methods: {...},
css: `...`,
render: ({ component, each, when, state, methods }) => {
return `
<div class="store">
<div class="store-items">
${each(items, (item) => {
return component(StoreItem, { item });
})}
</div>
<div class="cart">
<div class="cart-items">
${when(state.cart.length === 0, `
<div class="placeholder">
<p>You don't have any items in your cart. Drag and drop items from above to add them to your cart.</p>
</div>
`)}
${each(state.cart, (item) => {
return component(StoreItem, {
item,
onRemove: (itemId) => {
methods.handleRemoveItem(itemId);
},
});
})}
</div>
<footer>
<h2>Total: ${methods.getCartTotal()}</h2>
</footer>
</div>
</div>
`;
},
});
export default Store;
Above, we've added an additional property to our component options lifecycle
, and on that, we've added a function onMount
. Like the name suggests, this function is called by Joystick when our component is initially rendered or mounted in the browser.
For our drag-and-drop, we want to use this because we need to ensure that the elements we want to turn into drag-and-drop lists are actually rendered in the browser—if they're not, our Sortable will have nothing to "attach" its functionality to.
Inside of onMount
, we take in the component
instance (automatically passed to us by Joystick) and make two calls to component.DOMNode.querySelector()
, one for our store-items
list and one for our cart-items
list.
Here, component.DOMNode
is provided by Joystick and contains the actual DOM element representing this component as it's rendered in the browser. This allows us to interact with the raw DOM (as opposed to the Joystick instance or virtual DOM) directly.
Here, we're calling to .querySelector()
on that value to say "inside of this component, find us the element with the class name store-items
and the element with the class name cart-items
. Once we have these, next, we create our Sortable instances for each list (these will add the necessary drag-and-drop functionality) by calling Sortable.create()
and passing the element we retrieved from the DOM as either storeItems
or storeCart
.
For the first Sortable instance—for storeItems
—our definition is a bit simpler. Here, we specify the group
property which allows us to create a "linked" drag and drop target using a common name (here we're using store
). It also allows us to configure the behavior of the drag-and-drop for this list.
In this case, we want to "clone" elements from our shop list when we drag them (as opposed to moving them entirely) and we do not want to allow items to be put
back into the list. Additionally, we do not want our list to be sortable (meaning the order can be changed by dragging and dropping).
Beneath this, for our second sortable instance, we follow a similar pattern, however under the group
setting, for pull
we pass true
and for put
we pass true
(meaning items can be pulled and put into this list via drag-and-drop). Similar to our store items list, we also disable sort
.
The important part here is the onAdd()
function. This is called by Sortable whenever a new item is added or dropped into a list. Our goal here is to acknowledge the drop event and then add the item that was dropped into our cart on state.
Because Sortable modifies the DOM directly when dragging and dropping, we need to do a little bit of work. Our goal is to only let Joystick render the list of items in our cart into the DOM. To do it, we have to dynamically remove the DOM items that Sortable adds before we update our state so that we don't break the render.
To get there, we take in the DOM event
passed to us by sortable and locate the list item we're trying to add to our cart in the DOM. To do it, we call .querySelector()
on event.item
—the DOM element representing the dropped item in Sortable—and look for an element inside of that with a data-id
attribute (the store item).
Once we have this, we do a JavaScript Array.find() on our static items
list we defined earlier to see if we can find any objects with an id
matching the value of data-id
on the dropped element.
If we do, next, like we hinted at above, we remove the DOM element created in our list by Sortable with event?.item?.parentNode.removeChild(event.item)
. Once this is done, we call to update our component state with component.setState()
setting cart equal to an array that spreads (copies) the current contents of component.state.cart
and adds in a new object which consists of the found item
(we use the JavaScript spread ...
operator to "unpack the contents of it onto a new object) and an id
which is the id
of the item being dropped followed by -${component.state?.cart?.length + 1}
.
We do this because the id
of items in our cart needs to have some uniqueness to it if and when we drag multiples of the same item into the cart (here we just suffix a number on the end to make it just unique enough).
That's it! Now, when we drag an item from our store list down to our cart, we'll see the item added automatically. We'll also see the total we rendered via methods.getCartTotal()
update with the new value.
Wrapping up
In this tutorial, we learned how to wire up a drag-and-drop UI using SortableJS. We learned how to create a page with two separate lists, connecting them together as a group, and learning how to manage the drag-and-drop interaction between them. We also learned how to leverage state
inside of a Joystick component to render out items dynamically based on user interaction.