Step-by-Step Guide. Creating a React Hook for Web Page Element Movement Using RxJS

There are certain tasks for which RxJS (and overall reactive programming paradigm) is ideally suited. One such task is combining multiple "streams" of events to create a particular gesture. In this article, we will step-by-step write an universal React hook that allows attaching a movement gesture to any HTML element.

Why Choose These Approaches #

It is possible to combine 3 event emitters (for pointerdown, pointermove, pointerup events) without using RxJS, but I hope as the narrative progresses, you will appreciate the compactness and elegance of the solution using RxJS. In addition to the aesthetic sensations, there is an objective reason - it is difficult, if not impossible, to write tests for "pure" event emitters.

Instead of MouseEvents, the relatively new standard PointerEvents is used, which eliminates the need for writing specific code for mobile devices.

What We Get in the End #

We'll get an application like this - https://codesandbox.io/p/sandbox/react-usedraggable-hook-on-rxjs-with-composable-refs-vz3pp

Example screenshot
You will be able to move the grey div vertically.

Step by Step #

First, let's define the design of our solution. Here are its main elements:

Drag Gesture Module #

A drag gesture is a gesture of moving an object across the screen. It consists of a composition of events:

Sequence of Events

Here is a code for the logic:

export type DragEvent = { x: number; y: number };
export function createDragObservable<T extends PointerEvent>(
  up$: Observable<T>,
  down$: Observable<T>,
  move$: Observable<T>
): Observable<DragEvent> {
  let startPosition: DragEvent;
  return down$.pipe(
    mergeMap((e) => {
      startPosition = startPosition || { x: e.pageX, y: e.pageY };
      return move$.pipe(
        takeUntil(up$),
        map((e) => ({
          x: e.pageX - startPosition.x,
          y: e.pageY - startPosition.y,
        }))
      );
    })
  );
}

Yes, at first glance, RxJS seems to have a not very understandable API, but once you get used to it, you really start to enjoy how compactly you can describe complex operations.

What is happening here:

The arguments end with a $ not because of a craving for money (off-screen laughter), but to indicate that these variables are streams. This is a generally accepted convention in RxJS.

The function returns a new stream that sends events only when the mouse is actually moving after clicking on the element.

The function's body can be literally read as: we start listening to the stream of mouse click events (down$), and when an event occurs, we switch to listening to another stream, which will be returned by the function inside the mergeMap operator.

return down$.pipe(
  mergeMap((e) => {
    startPosition = startPosition || { x: e.pageX, y: e.pageY };
    return move$.pipe(
      takeUntil(up$),
      map((e) => ({
        x: e.pageX - startPosition.x,
        y: e.pageY - startPosition.y,
      }))
    );
  })
);

The new stream in mergeMap is "listening" to the mouse pointer movement events move$, which we listen to until an event from the "pointer release" stream up$ appears (this is handled by the takeUntill operator).

All events from the move$ stream are transformed (map operator) into relative movement. It is relative to the initial position of the element.

The test for this logic looks like this:

import { marbles } from "rxjs-marbles/jest";
import { createDragObservable } from "./use-draggable";

const data = {
  d: new PointerEvent("mousedown"),
  m: new PointerEvent("mousedown"),
  u: new PointerEvent("mousedown"),
};

describe("useDraggable", () => {
  it(
    "emits drag events only after mousedown and end after mouseup",
    marbles((m) => {
      const down$ = m.hot("-d--------", data);
      const move$ = m.hot("mmmmm-mmmm", data);
      const up$ = m.hot__("-------u--", data);
      const expectedDrag$ = m.hot("-eeee-ee--", { e: { x: 0, y: 0 } });

      const drag$ = createDragObservable(up$, down$, move$);
      m.expect(drag$).toBeObservable(expectedDrag$);
    })
  );
});

Here, the library that facilitates testing RxJS is used - https://github.com/cartant/rxjs-marbles

Its API is based on the same scheme for describing streams that is used to explain a solution that uses streams - marble diagram, or bead diagram. A typical diagram might look like this:

typical diagram rxjs

The diagram explains the principle of the takeUntill operator.
In this example, the first thread is a stream of some events (in our case, it is the move$ stream - pointer movement), the second stream is the argument of the takeUntil operator, an emit event in this stream "stops" the emit event in the resulting stream (in our case, the emit event "pointer lifted" in the up$ stream stops listening to pointer movement events).

Similarly, our test on rxjs-marbles is read:

const down$ = m.hot("-d--------", data);
const move$ = m.hot("mmmmm-mmmm", data);
const up$ = m.hot__("-------u--", data);
const expectedDrag$ = m.hot("-eeee-ee--", { e: { x: 0, y: 0 } });

drag$ is the stream created by our function, expectedDrag$ are the values that drag$ should emit after processing the streams.

Accordingly, the line:

m.expect(drag$).toBeObservable(expectedDrag$);

launches the test check.

Universal React Hook for Adding "Movability" to HTML Elements #

Hook's code:

export function useDraggable(draggableRef: React.RefObject<HTMLElement>) {
  const drag$ = useRef<Observable<DragEvent>>();
  useLayoutEffect(() => {
    if (!draggableRef.current) {
      return () => {};
    }
    const down$ = fromEvent<PointerEvent>(draggableRef.current, "pointerdown");
    const move$ = fromEvent<PointerEvent>(document, "pointermove");
    const up$ = fromEvent<PointerEvent>(document, "pointerup");
    drag$.current = createDragObservable(up$, down$, move$);
  }, [draggableRef]);

  return drag$;
}

The hook takes a ref as input, which will contain a link to the HTML element to which we are adding the ability to move.

Since the down$ event stream can only be obtained after react renders all HTML elements (componentDidMount, or the function in useEffect, useLayoutEffect hooks), we will use useRef to create a mutable container into which we will write the drag gesture stream.

This RefObject is what we return from the hook.

Using the Hook in a Component #

The code of the component in which we use all this looks like this:

Source code of component

We have created a container (ref-object), into which react will place a link to the rendered HTML element - draggableDivRef. This object was given as an argument in our hook - useDraggable.

In the useLayoutEffect hook, we described the logic of reacting to events - we update the position of the element along the Y-axis by setting the style:

draggableDivRef.current.style.transform = `translateY(${e.y}px)`;

And do not forget to unsubscribe from all events:

return () => {
  dragSubscription.unsubscribe();
};

And this is actually a very important part of our RxJS-based solution - we unsubscribed from the drag$ event stream, but in fact, since it consists of a combination of three other streams, there was also an unsubscribe from these three streams (rup$, down$, move$). And this is one of the key selling points of RxJS-based solutions compared to working with traditional Event Emitter - in Event Emitter there is no cascading unsubscribe from events, and you have to handle this yourself in the code, and it is often difficult to keep track of.

The second key advantage of RxJS over the usual Event Emitter is the ability to test all the components of the solution: the beginning of the subscription to events, the sequence of events between several streams, the values emitted by the streams at one time or another, and the end of the subscription to events.

How the Solution Can Be Improved Further #

Add support for `pointercancel`` events and others to cancel the gesture not only by lifting the pointer, but also by an incoming call, for example. You can learn more about working with PointerEvents and gestures in general in the lecture I prepared for the School of Interface Development at Yandex - https://www.youtube.com/watch?v=VZAcd2svW7w

It is also worth writing tests that take into account not only the order of events but also specific movement values. That is, to test that if there were two pointermove events with a 10px shift each, then the total shift will be 20px.

Conclusion #

Yes, to solve not the most complex problem, we touched on so many topics: react, hook, refs, useEffect, rxjs, marbles, jest, and many others. Someone will say that this is over-engineering (i.e., too complex a solution to a simple problem) and they may be right, it all depends on the context!

If you need an object movement gesture, you can use one of a dozen libraries, but usually 90% of the code that you will include to JS bundle won't be used (since it's usually a generic solution for various use cases). Typically, they do not have tests. However, if you have a startup, then this is a quite workable option.

You could have avoided using RxJS, but I can't imagine a solution that would be read and understood faster, would be more isolated, and for which it would be easier to write tests. If you, dear reader, know of such a thing - tell me in twitter/x! I sincerely wish to see it!

Resources for Learning RxJS #

#javascript #typescript #rxjs

***

Since you've made it this far, sharing this article on your favorite social media network would be highly appreciated 💖! For feedback, please ping me on Twitter.

Published