Skip to content

How to implement drag & drop using RxJS

This article was written over 18 months ago and may contain information that is out of date. Some content may be relevant but please refer to the relevant official documentation or available resources for the latest information.

Drag & drop is one of the features that can be very useful for the end-users of our application. Additionally, it is a great example to show how RxJS can be used to handle drag-and-drop functionality with ease. Let's see how we can implement the simple dragging behavior.

To follow along with all the code examples in this article, I recommend opening this Stackblitz starter example. All examples will be based on this starter project.

Define drag and drop

Before we start the implementation, let's consider what the drag and drop functionality consists of. It can be split into 3 phases:

  • drag start
  • drag move
  • drag end (drop)

In a nutshell, drag start happens whenever we press mouse down on a draggable item. Following that each time we move a cursor a drag move event should be emitted. Drag move should continue, but only until we release the mouse button (mouse up event).

Basic implementation

You might have noticed that a few of the words above are bolded. This is because those specific words give us a clue on how we can implement the described behavior. For starters, we can see that 3 native events will be necessary to implement our feature:

  • mousedown - for starting the dragging
  • mousemove - for moving the dragged element
  • mouseup - for ending the dragging (dropping an element)

Let's first create Observables out of those events. They will be our basic building blocks.

import { fromEvent } from 'rxjs'

const draggableElement = document.getElementById('dragMe');

const mouseDown$ = fromEvent(draggableElement, 'mousedown');
const mouseMove$ = fromEvent(draggableElement, 'mousemove');
const mouseUp$ = fromEvent(draggableElement, 'mouseup');

We now have our base events. Now, let's create our drag event from them.

import { switchMap, takeUntil } from 'rxjs/operators';

const dragStart$ = mouseDown$;
const dragMove$ = dragStart$.pipe( // whenever we press mouse down
    switchMap(() => mouseMove$).pipe( // each time we move a cursor
      takeUntil(mouseUp$) // but only until we release the mouse button
    ),
);

As you can see, due to the very declarative syntax of RxJS, we were able to transform the previous definition.

This is a good start, but we need a bit more information in the dragMove$ Observable so that we know how far we drag the element. For that, we can use the value emitted by dragStart$, and compare it with each value emitted by mouseMove$:

const dragMove$ = dragStart$.pipe(
  switchMap(start =>
    mouseMove$.pipe(
      // we transform the mouseDown and mouseMove event to get the necessary information
      map(moveEvent => ({
        originalEvent: moveEvent,
        deltaX: moveEvent.pageX - start.pageX,
        deltaY: moveEvent.pageY - start.pageY,
        startOffsetX: start.offsetX,
        startOffsetY: start.offsetY
      })),
      takeUntil(mouseUp$)
    )
  ),
);

Now, our Observable emits all necessary information for us to move the dragged element with the mouse moving. Since observables are lazy, we need to subscribe it to perform any action.

dragMove$.subscribe(move => {
  const offsetX = move.originalEvent.x - move.startOffsetX;
  const offsetY = move.originalEvent.y - move.startOffsetY;
  draggableElement.style.left = offsetX + 'px';
  draggableElement.style.top = offsetY + 'px';
});

This works well, but only if we don't move the mouse too fast. This is because our mouseMove$ and mouseUp$ events are listening on the dragged element itself. If the mouse moves too fast, the cursor can leave the dragged element, and then we will stop receiving the mousemove event. The easy solution to this is to target mouseMove$ and mouseUp$ to the document so that we receive all the mouse events even if we leave the dragged element for a moment.

const mouseMove$ = fromEvent(document, 'mousemove');
const mouseUp$ = fromEvent(document, 'mouseup');

This small change will improve the dragging behavior so that we can move the cursor freely around the whole document.

Before we continue, let's clean the code by extracting the logic we've created into a function.

const mouseMove$ = fromEvent(document, 'mousemove');
const mouseUp$ = fromEvent(document, 'mouseup');

const draggableElement = document.getElementById('dragMe');

createDraggableElement(draggableElement);

function createDraggableElement(element) {
  const mouseDown$ = fromEvent(element, 'mousedown');

  const dragStart$ = mouseDown$;
  const dragMove$ = dragStart$.pipe(
    switchMap(start =>
      mouseMove$.pipe(
        map(moveEvent => ({
          originalEvent: moveEvent,
          deltaX: moveEvent.pageX - start.pageX,
          deltaY: moveEvent.pageY - start.pageY,
          startOffsetX: start.offsetX,
          startOffsetY: start.offsetY
        })),
        takeUntil(mouseUp$)
      )
    )
  );

  dragMove$.subscribe(move => {
    const offsetX = move.originalEvent.x - move.startOffsetX;
    const offsetY = move.originalEvent.y - move.startOffsetY;
    element.style.left = offsetX + 'px';
    element.style.top = offsetY + 'px';
  });
}

This way, we can easily make our code so that it allows for multiple draggable elements:

appDiv.innerHTML = `
  <h1>RxJS Drag and Drop</h1>
  <div class="draggable"></div>
  <div class="draggable"></div>
  <div class="draggable"></div>
`;

const draggableElements = document.getElementsByClassName('draggable');

Array.from(draggableElements).forEach(createDraggableElement);

In case you have any trouble during any of the steps, you can compare your solution with this example.

Emitting custom events

The above example shows that it is possible to implement a simple dragging behavior using RxJS. In real-life examples, it might be very useful to have a custom event on a draggable element so that it is easy to register your custom function to any part of the drag & drop lifecycle.

It the previous example, we defined dragStart$ and dragMove$ observables. We can use those directly to start emitting mydragstart and mydragmove events on the element accordingly. I've added a my prefix to make sure I don't collide with any native event.

  import { tap } from 'rxjs/operators';

   dragStart$
    .pipe(
      tap(event => {
        element.dispatchEvent(
          new CustomEvent('mydragstart', { detail: event })
        );
      })
    )
    .subscribe();

  dragMove$
    .pipe(
      tap(event => {
        element.dispatchEvent(
          new CustomEvent('mydragmove', { detail: event })
        );
      })
    )
    .subscribe();

As you might see in the example above, I'm putting dispatching logic into a tap function. This is an approach I recommend as this allows us to combine multiple observable streams into one and call subscribe only once:

import { combineLatest } from 'rxjs';

combineLatest([
    dragStart$.pipe(
      tap(event => {
        element.dispatchEvent(
          new CustomEvent('mydragstart', { detail: event })
        );
      })
    ),
    dragMove$.pipe(
      tap(event => {
        element.dispatchEvent(
          new CustomEvent('mydragmove', { detail: event })
        );
      })
    )
  ]).subscribe();

Now the only event missing is mydragend. This event should be emitted as the last event of the mydragmove event sequence. We can again use the RxJS operator to achieve such behavior.

  const dragEnd$ = dragStart$.pipe(
    switchMap(start =>
      mouseMove$.pipe(
        startWith(start),
        map(moveEvent => ({
          originalEvent: moveEvent,
          deltaX: moveEvent.pageX - start.pageX,
          deltaY: moveEvent.pageY - start.pageY,
          startOffsetX: start.offsetX,
          startOffsetY: start.offsetY
        })),
        takeUntil(mouseUp$),
        last(),
      )
    )
  );

Note that because we are using the last() operator we must make sure that the stream that we apply this operator to must emit at least one value. Otherwise it will fail with Error: no elements in sequence. To achieve that we provide the initial value using startWith operator to pass the initial mouse position. This is important for the edge case of clicking and releasing the element without actually moving it.

And the last step would be to emit this event alongside the others

combineLatest([
    dragStart$.pipe(
      tap(event => {
        element.dispatchEvent(
          new CustomEvent('mydragstart', { detail: event })
        );
      })
    ),
    dragMove$.pipe(
      tap(event => {
        element.dispatchEvent(new CustomEvent('mydragmove', { detail: event }));
      })
    ),
    dragEnd$.pipe(
      tap(event => {
        element.dispatchEvent(new CustomEvent('mydragend', { detail: event }));
      })
    )
  ]).subscribe();

This concludes the implementation. We can now use those events any way we want to.

Array.from(draggableElements).forEach((element, i) => {
  element.addEventListener('mydragstart', () =>
    console.log(`mydragstart on element #${i}`)
  );

  element.addEventListener('mydragmove', event =>
    console.log(
      `mydragmove on element #${i}`,
      `delta: (${event.detail.deltaX}, ${event.detail.deltaY})`
    )
  );

  element.addEventListener('mydragend', event =>
    console.log(
      `mydragend on element #${i}`,
      `delta: (${event.detail.deltaX}, ${event.detail.deltaY})`
    )
  );
});

You can find the whole implementation here, or you can play with it below:

Conclusion

In this article, I've shown you that you can easily implement a basic drag-and-drop behavior by using RxJS. It is a great tool for this use case as it makes managing the stream of events easier, and allows for the very declarative implementation of complex behaviors.

If you are looking for more interesting examples of how you can use the drag-and-drop events with RxJS, I recommend visiting this example.

In case you have any questions, you can always tweet or DM me at @ktrz. I'm always happy to help!

This Dot is a consultancy dedicated to guiding companies through their modernization and digital transformation journeys. Specializing in replatforming, modernizing, and launching new initiatives, we stand out by taking true ownership of your engineering projects.

We love helping teams with projects that have missed their deadlines or helping keep your strategic digital initiatives on course. Check out our case studies and our clients that trust us with their engineering.

Let's innovate together!

We're ready to be your trusted technical partners in your digital innovation journey.

Whether it's modernization or custom software solutions, our team of experts can guide you through best practices and how to build scalable, performant software that lasts.

Prefer email? hi@thisdot.co