DEV Community

Jonathan Gamble
Jonathan Gamble

Posted on

Svelte 5 and SortableJS

Sortable in Svelte 5

I've been scratching my head trying to find a way to drag and drop sort in Svelte. I build some things form scratch, but they were really not maintainable. I do not want to maintain a repository at this point, although that could change.

SortableJS

SortableJS works in every other framework, why not Svelte 5? Instead of building a custom component, I figured I would build a custom hook.

Enter useSortable

First, install the necessary packages.

npm i -D sortablejs @types/sortablejs
Enter fullscreen mode Exit fullscreen mode

🟥 It is always a red flag when we have to install separate types. With the exception of JSDoc and Svelte core coding, I don't trust the work on non-Typescript developers. That being said, maybe this package is so old that it has been modified since before TS? Either way, it is maintained, so I trust it better than building something.

Create Our Hook

import Sortable from 'sortablejs';

export const useSortable = (
    getter: () => HTMLElement | null,
    options?: Sortable.Options
) => {
    $effect(() => {
        const sortableEl = getter();
        const sortable = sortableEl ?
            Sortable.create(sortableEl, options)
            : null;
        return () => sortable?.destroy();
    });
}
Enter fullscreen mode Exit fullscreen mode

That's it! Well, sort of...

If we want to save our sort order, we will need a function to return our new items.

export function reorder<T>(
    array: T[],
    evt: Sortable.SortableEvent
): $state.Snapshot<T>[] {

    // should have no effect on stores or regular array
    const workArray = $state.snapshot(array);

    // get changes
    const { oldIndex, newIndex } = evt;

    if (oldIndex === undefined || newIndex === undefined) {
        return workArray;
    }
    if (newIndex === oldIndex) {
        return workArray;
    }

    // move elements
    const target = workArray[oldIndex];
    const increment = newIndex < oldIndex ? -1 : 1;

    for (let k = oldIndex; k !== newIndex; k += increment) {
        workArray[k] = workArray[k + increment];
    }
    workArray[newIndex] = target;
    return workArray;
}
Enter fullscreen mode Exit fullscreen mode

Usage

We must pass in our parent HTML Element. The children get sorted.

<script lang="ts">
import { reorder, useSortable } from '$lib/use-sortable.svelte';
...
let sortable = $state<HTMLElement | null>(null);

useSortable(() => sortable);
...
</script>
...
<ul bind:this={sortable}>
  <li>Child 1</li>
  <li>Child 2</li>
  ...
Enter fullscreen mode Exit fullscreen mode

We can also pass all the options for SortableJS as the second parameter. See SortableJS Docs.

useSortable(() => sortable, {
    animation: 200,
    handle: '.my-handle',
    ghostClass: 'opacity-0'
});
Enter fullscreen mode Exit fullscreen mode

Saving the New State

Now, we need to save the state of our new sortable array.

let items = $state([
    {
        id: 1,
        text: 'Item 1'
    },
    {
        id: 2,
        text: 'Item 2'
    },
    {
        id: 3,
        text: 'Item 3'
    }
]);

let sortable = $state<HTMLElement | null>(null);

useSortable(() => sortable, {
    animation: 200,
    handle: '.my-handle',
    ghostClass: 'opacity-0',
    onEnd(evt) {
        items = reorder(items, evt);
    }
});
Enter fullscreen mode Exit fullscreen mode

The onEnd event handler returns an event object containing the new and old positions for our array after the items have been dropped. We can reorder our original array by importing our reorder function, and passing this event to it.

Each Loop

Make sure to have our array use the entire value as the key, this is the item in ().

{#each items as item (item)}
...
{/each}
Enter fullscreen mode Exit fullscreen mode

Final Code

<script lang="ts">
    import Handle from '$lib/handle.svelte';
    import { reorder, useSortable } from '$lib/use-sortable.svelte';

    let items = $state([
        {
            id: 1,
            text: 'Item 1'
        },
        {
            id: 2,
            text: 'Item 2'
        },
        {
            id: 3,
            text: 'Item 3'
        }
    ]);

    let sortable = $state<HTMLElement | null>(null);

    useSortable(() => sortable, {
        animation: 200,
        handle: '.my-handle',
        ghostClass: 'opacity-0',
        onEnd(evt) {
            items = reorder(items, evt);
        }
    });
</script>

<div class="hidden opacity-0"></div>
<ul class="flex w-full list-none flex-col items-center" bind:this={sortable}>
    {#each items as item (item)}
        <li class="m-2 flex w-32 items-center justify-center gap-5 border p-3">
            <span>{item.text}</span>
            <button type="button" class="my-handle outline-none">
                <Handle />
            </button>
        </li>
    {/each}
</ul>
<div class="flex justify-center">
    <pre class="mt-5 w-fit border p-5">{JSON.stringify(items, null, 2)}</pre>
</div>
Enter fullscreen mode Exit fullscreen mode

Ghost Class

I don't want to add a style tag, so for my ghost class I am using:

<div class="hidden opacity-0"></div>
Enter fullscreen mode Exit fullscreen mode

This ensures opacity-0 gets compiled from Tailwind.

Final Hook

// use-sortable.svelte.ts

import Sortable from 'sortablejs';

export const useSortable = (
    getter: () => HTMLElement | null,
    options?: Sortable.Options
) => {
    $effect(() => {
        const sortableEl = getter();
        const sortable = sortableEl ?
            Sortable.create(sortableEl, options)
            : null;
        return () => sortable?.destroy();
    });
}

export function reorder<T>(
    array: T[],
    evt: Sortable.SortableEvent
): $state.Snapshot<T>[] {

    // should have no effect on stores or regular array
    const workArray = $state.snapshot(array);

    // get changes
    const { oldIndex, newIndex } = evt;

    if (oldIndex === undefined || newIndex === undefined) {
        return workArray;
    }
    if (newIndex === oldIndex) {
        return workArray;
    }

    // move elements
    const target = workArray[oldIndex];
    const increment = newIndex < oldIndex ? -1 : 1;

    for (let k = oldIndex; k !== newIndex; k += increment) {
        workArray[k] = workArray[k + increment];
    }
    workArray[newIndex] = target;
    return workArray;
}
Enter fullscreen mode Exit fullscreen mode

We use $state.snapshot to get rid of the proxy and create a clone array. Then we set the data back in our onEnd statement.

Stores

This works with stores as well. I hate supporting something that will get depreciated, but unfortunately, we have too many packages that may never update to runes, like Superforms. Keep in mind certain components in shadcn-svelte depend on Superforms, so we can be stuck with slower performance for many packages. I, personally, hope stores get depreciated sooner than later so we can have a better Svelte.

Demo: Vercel
Repo: GitHub

Hope this helps someone,

J

Top comments (4)

Collapse
 
webjose profile image
José Pablo Ramírez Vargas

Good work, but why the hooks pattern? That's too "reacty" for my taste. Svelte has this amazingly powerful (and soon to mutate to an even more powerful "attachment") feature called "actions". Make an action.

Collapse
 
jdgamble555 profile image
Jonathan Gamble • Edited

You could easily do that. I just prefer the hook pattern as I find it to be super clean. It is also what huntabyte does for Runed with the exception of classes.

Collapse
 
webjose profile image
José Pablo Ramírez Vargas

That would be huntabyte, and I still don't like it. I find actions to be much cleaner. Attachments will replace actions and will be available for components, so it might be a good idea to drop the hooks syntax. Just friendly advice.

Thread Thread
 
jdgamble555 profile image
Jonathan Gamble

Typo fixed :) Hooks and the use syntax are popular in all frameworks, including Svelte and Vue, and are just a preference. I doubt they will go away completely for situations where you don't attach directly to an element. That being said, when attachement makes it to production and they finalize the syntax, this would be a good use case to attach directly to the element instead of passing it as a parameter, so I can look at updating it then.