Search code examples
statesvelteinteract.jssvelte-5

Svelte 5 reactive $state in external module (dragListener)


I am trying to refactor a dragListener into a .svelte.js module by defining the position of the dragged element as a reactive $state() variable inside +page.svelte and pass it back and forth between the module and the +page.svelte.

The Svelte 5 Docs provide an example where let position = $state() is defined inside the external module (returning a getter function). But what shall I do, if I need to define let position = $state() inside +page.svelte?

Below I provide a minimal working example where the position of the element can be changed via a button and via drag. For both I provide handles in a helpers.svelte.js file. Inside +page.svelte I use $inspect(position) to check, whether state has updated. While inspect will fire on button click, it does not on drag.

I suppose, however, that the button-event only appears to be reactive because position is returned by handleClick(). While the lack of true reactivity is ok for a simple click action, it is an issue in the case of the drag listener.

So, how can I make position truly reactive?

Btw: I would be fine with using stores, too. However I don't get them working in external svelte.js files either. Also, according to the above linked Svelte 5 Docs, there should be a way to solve this with the $state() rune.

+page.svelte

<svelte:options runes="{true}" />
<script>
    import { onMount } from "svelte";
    import {handleIncreaseX, handleDrag} from '$lib/helpers.svelte'
    
    let item;
    let position = $state({x:0, y:0});

    $inspect(position); //??? why is it only reacting to the button but not to drag?
    
    let onclick = () => {
        position = handleIncreaseX(item, position)
    }
    
    onMount(() => { 
        position = handleDrag(item, position);
    });
</script>


<h1>Position: {position.x} / {position.y}</h1>
<button {onclick}>Increase X</button>


<div id="draggable" bind:this={item}>Drag Me</div>

<style>
    #draggable {
        background-color: black;
        color: white;
        width: 200px;
    }
</style>

helpers.svelte.js

import interact from 'interactjs';

export function handleIncreaseX(item, position) {
    position.x += 1;
    item.style.webkitTransform =  item.style.transform = `translate(${position.x}px,${position.y}px)`;
    item.setAttribute('data-x', position.x);
    item.setAttribute('data-y', position.y);
    return position;
}


export function handleDrag(item, position) {

    console.log('DRAG INIT')

    interact(item).draggable({
        inertia: false,
        autoScroll: false,
        onstart: (ev) => {
            console.log('ONSTART');
        },
        
        onmove: (ev) => {
            let el = ev.target;

            // store the dragged position inside data-x/data-y attributes
            let x = (parseFloat(el.getAttribute('data-x')) || 0) + ev.dx;
            let y = (parseFloat(el.getAttribute('data-y')) || 0) + ev.dy;
        
            // translate the element and update position attributes
            el.style.webkitTransform =  el.style.transform = `translate(${x}px,${y}px)`;
            el.setAttribute('data-x', x);
            el.setAttribute('data-y', y);
            position = {x:x, y:y};

            console.log('ONMOVE', position);
        },

        onend: (ev)=>{
            console.log('ONEND');
        }
    });
    return position;
}

Note: Reactivity does work perfectly fine, as long as I define the drag listener inside the +page.svelte. However, this is not an option for me.

+page.svelte

<svelte:options runes="{true}" />
<script>
    import { onMount } from "svelte";
    import interact from 'interactjs';

    import {handleIncreaseX} from '$lib/helpers.svelte'
    
    let item;
    let position = $state({x:0, y:0});
    
    $inspect(position);
    
    let onclick = () => {
        position = handleIncreaseX(item, position)
    }
    
    onMount(() => { 
        console.log('DRAG INIT')

        interact(item).draggable({
            inertia: false,
            autoScroll: false,
            onstart: (ev) => {
                console.log('ONSTART');
            },
            
            onmove: (ev) => {
                let el = ev.target;

                // store the dragged position inside data-x/data-y attributes
                let x = (parseFloat(el.getAttribute('data-x')) || 0) + ev.dx;
                let y = (parseFloat(el.getAttribute('data-y')) || 0) + ev.dy;
            
                // translate the element and update position attributes
                el.style.webkitTransform =  el.style.transform = `translate(${x}px,${y}px)`;
                el.setAttribute('data-x', x);
                el.setAttribute('data-y', y);
                position = {x:x, y:y};

                console.log('ONMOVE', position);
            },

            onend: (ev)=>{
                console.log('ONEND');
            }
        });
    });
</script>


<h1>Position: {position.x} / {position.y}</h1>
<button {onclick}>Increase X</button>


<div id="draggable" bind:this={item}>Drag Me</div>

<style>
    #draggable {
        background-color: black;
        color: white;
        width: 200px;
    }
</style>

Solution

  • In the drag function you are reassigning a function argument, this will never have any effect on the outside. You just need to modify the position instead.

    // instead of:
    position = {x:x, y:y};
    
    // do
    position.x = x;
    position.y = y;
    

    There also is no real reason to have the function return anything in that case. You just pass the state in and modify it on drag.

    Preview REPL1

    There are other approaches as well, e.g. the state could be created by the function and returned instead.

    E.g. returning state and an action:

    import interact from 'interactjs';
    
    export function draggable(initial) {
        const position = $state(initial);
    
        const action = item => interact(item).draggable({
            inertia: false,
            autoScroll: false,
            onmove: (ev) =>
            {
                // ...
                position.x = x;
                position.y = y;
            },
        });
    
        return [action, position];
    }
    

    Usage:

    <script>
        import { draggable } from './helpers.svelte.js';
        const [drag, position] = draggable({ x: 0, y: 0 });
    </script>
    <div id="draggable" use:drag>Drag Me</div>
    

    Preview REPL1


    1 Links might break later and require updating.