Contents

Implement onClick, onDoubleClick and onHold Streams with RxJS

Mieszko Sabo

21 Feb 2023.9 minutes read

Implement onClick, onDoubleClick and onHold Streams with RxJS webp image

This post will show a practical example of how we can use RxJS to create an ergonomic abstraction around onClick, onDoubleClick, and onHold events. You’ll see how easy it is to use RxJS primitives to create a declarative solution that is both readable and generic, so that we can then build upon it and effortlessly create other streams such as onTripleClick, etc.

These three gestures are pretty common in many hardware-based UIs such as watches, but nothing stops us from using them in more modern, digital designs as well.

image2

Defining our system

First, let’s think about what events are in our “system”. The source of all clicks and holds is the user pushing and releasing their finger from some button or screen. So the only two events that come to us from “outside” are the down event and the up event. Notice, that it doesn’t matter whether a user is touching a screen, pressing a key on a keyboard, or clicking with a mouse – the logic will remain the same. So let’s write that with RxJS:

import { Subject } from "rxjs";

export const downSubject = new Subject<"down">();
export const upSubject = new Subject<"up">();

const down$ = downSubject.asObservable();
const up$ = upSubject.asObservable();

Later we will use the subjects to transform some specific events, such as React’s MouseDown and MouseUp into our platform-agnostic logic.

It doesn’t matter what values are emitted by these streams, but we want to be able to differentiate them. Plain strings are fine, but if we ever wanted to attach some payloads to the events, such as the x, and y coordinates of the mouse at the time of the event emission, we could do something like this:

type Event<T extends string> = {
  type: T;
    // add payload here if needed
};

export const downSubject = new Subject<Event<"down">>(); // changed
export const upSubject = new Subject<Event<"up">>(); // changed

Ok, so what now? Let’s think about what a click is on the low level. When we, as the user, want to click something we:

  • just press it and release it right away
  • don’t want to feel like we are in some race and have to be too mindful about releasing our finger fast enough so that we don’t trigger some other action by accident

If we were to implement only clicks it would be trivial to do so:

const click$ = down$.pipe(
  switchMap(() =>
    up$.pipe(map(() => ({ type: "click" } as Event<"click">)))
  )
);

click$.subscribe(console.log);

Whenever a down event is emitted, subscribe to the up event stream, and when the up event is emitted map it to a click event.

Refining the click stream

By the way, here we assume that the merged stream of down and up events looks like this::

D--U-----D-U-----DU--DU---->

That is, we never get two down events in a row, and the same with up events. Is it a correct assumption?

Well, I can’t think of an interface that would allow us to press something twice (with a second finger?) and then release it once. But imagine that we are dealing with a faulty network link that can sometimes randomly duplicate some events.

For example, if the up event was duplicated then with our current implementation we would emit two clicks instead of one.

Luckily we can simply unsubscribe from the up$ after a single event:

const click$ = down$.pipe(
  switchMap(() =>
    up$.pipe(
            take(1) // new
        ).pipe(map(() => ({ type: "click" } as Event<"click">)))
  )
);

click$.subscribe(console.log);

Then whatever the amounts of ups and downs come, we’ll emit a single click per each unique pair of a down event followed by an up event. Nice!

We can see from this example that if we have a solid understanding of how streams work, we can often fulfill different requirements quite easily with a single RxJS operator.

Hold event stream

Let’s define the events that we want to output as well as some event creators:

type Click = Event<"click">;
type DoubleClick = Event<"doubleClick">;
type Hold = Event<"hold">;

const createClick = () => ({ type: "click" } as Click);
const createDoubleClick = () => ({ type: "doubleClick" } as DoubleClick);
const createHold = () => ({ type: "hold" } as Hold);

When a down event is emitted we have only two options: to map it to a click or to map it to a hold. We’ll emit a hold automatically after some time has passed without an up event. So it is a race between a timer that triggers the hold event and an up event that will trigger a click and dispose of the timer. Let’s do exactly this:

const HOLD_INTERVAL = 500;

const clickOrHold$ = down$.pipe(
  switchMap(() =>
    up$.pipe(
      map(createClick),
      raceWith(timer(HOLD_INTERVAL).pipe(map(createHold)))
    )
  )
);

clickOrHold$.subscribe(console.log);

For ease of use, we can create also dedicated streams for each gesture:

const click$ = clickOrHold$.pipe(
    filter((event) => event.type === "click")
);

const hold$ = clickOrHold$.pipe(
    filter((event) => event.type === "hold")
);

Double clicks

Great, so the only gesture left is a doubleClick. Since we have a stream that emits click events or hold events, we’ll use the filter operator to get just clicks and then we’ll measure the time between consecutive clicks and if it is close enough we’ll map it to doubleClick:

const DOUBLE_CLICK_INTERVAL = 300;

const doubleClick$ = click$.pipe(
  timeInterval(),
  filter(({ interval }) => interval < DOUBLE_CLICK_INTERVAL),
  map(createDoubleClick)
);

doubleClick$.subscribe(console.log);

We used the timeInterval operator, which adds an interval field with the amount of time elapsed between consecutive emissions.

Bringing everything together

It may seem that to create a stream that emits all gestures we can use a merge function:

// won't work as we would expect
export const gestures$ = merge(click$, hold$, doubleClick$);

But then, every time a user rapidly clicks two times in a row, the stream above will emit 3 events: click, click, and doubleClick! We probably would prefer if it emitted a single doubleClick instead.

Our first thought might be to implement it in the same way as we did in clickOrHold$:

// won't work!
const clickOrDoubleClick$ = click$.pipe(
  switchMap(() =>
    click$.pipe(
      map(createDoubleClick),
      raceWith(timer(HOLD_INTERVAL).pipe(map(createClick)))
    )
  )
);

Unfortunately, that won’t work because the outer observable is the same as the inner observable in switchMap, so whenever a second click comes we immediately unsubscribe from the inner observable and don’t emit the doubleClick event.

Instead, we’ll use another common technique. We’ll buffer the incoming clicks into an array and then emit an event based on the array’s length:

const DOUBLE_CLICK_INTERVAL = 200;

const clickOrDoubleClick$ = click$.pipe(
  buffer(click$.pipe(switchMap(() => timer(DOUBLE_CLICK_INTERVAL)))),
  map((clicksArr) =>
    clicksArr.length > 1 ? createDoubleClick() : createClick()
  )
);

The buffer operator accumulates values until the observable, passed as its parameter, emits. So the trick is to pass a correct parameter. Here I passed an observable that emits 0 (timer emits 0, the value doesn’t matter in this case) 200ms after each click event. If a user clicks 2 (or more) times during that time the array will contain more than 1 element and we’ll map it to doubleClick.

Now we can finally create a correct gestures stream:

export const gestures$ = merge(clickOrDoubleClick$, hold$);

Complete code and usage example

Here’s everything we wrote:

import {
  buffer,
  filter,
  map,
  merge,
  raceWith,
  Subject,
  switchMap,
  timer,
} from "rxjs";

type Event<T extends string> = {
  type: T;
};

export const downSubject = new Subject<Event<"down">>();
export const upSubject = new Subject<Event<"up">>();

const down$ = downSubject.asObservable();
const up$ = upSubject.asObservable();

export type Click = Event<"click">;
export type DoubleClick = Event<"doubleClick">;
export type Hold = Event<"hold">;

const createClick = () => ({ type: "click" } as Click);
const createDoubleClick = () => ({ type: "doubleClick" } as DoubleClick);
const createHold = () => ({ type: "hold" } as Hold);

const HOLD_INTERVAL = 500;

const clickOrHold$ = down$.pipe(
  switchMap(() =>
    up$.pipe(
      map(createClick),
      raceWith(timer(HOLD_INTERVAL).pipe(map(createHold)))
    )
  )
);

const click$ = clickOrHold$.pipe(filter((event) => event.type === "click"));

const hold$ = clickOrHold$.pipe(filter((event) => event.type === "hold"));

const DOUBLE_CLICK_INTERVAL = 200;

const clickOrDoubleClick$ = click$.pipe(
  buffer(click$.pipe(switchMap(() => timer(DOUBLE_CLICK_INTERVAL)))),
  map((clicksArr) =>
    clicksArr.length > 1 ? createDoubleClick() : createClick()
  )
);

const doubleClick$ = clickOrDoubleClick$.pipe(
  filter(({ type }) => type === "doubleClick")
);

export const gestures$ = merge(hold$, clickOrDoubleClick$);

Let’s see an example of how we can use these streams in some app. I’ve created a new Vite project form React and Typescript template and modified the App.tsx like this:

import "./App.css";
import { downSubject, gestures$, upSubject } from "./gestures";

gestures$.subscribe((gesture) => {
  console.log(gesture);
});

function App() {
  return (
    <div className="App">
      <button
        onMouseDown={() => {
          downSubject.next({ type: "down" });
        }}
        onMouseUp={() => {
          upSubject.next({ type: "up" });
        }}
      >
        Button
      </button>
    </div>
  );
}

export default App;

Exercise for the reader

I propose the following challenge to practice what we have learned today.

Create a ClickStreak$, a stream that emits numbers (to make it simpler) and it should work like this:

  • If the user clicks a button n times with each click within 200ms from the last click the stream will emit n.

So if someone clicks a button once and then waits for 0.2s then 1 will be emitted. If someone rapidly clicks that button 17 times, then only number 17 will be emitted, etc.

Bonus: State Machines

Even though our implementation with streams is much better than some usual imperative spaghetti code full of if statements, variables, and setTimeouts, there is another approach that could be even better: state machines.

If you are not familiar with state machines, I won't go into detail, but basically, we model our system as a set of all possible states that it can be in and all possible transitions between those states, which are usually triggered by some events.

Here's a diagram of how we could model our system with state machines:
modeling system with state machines

  • We start in a state called default and we can only transition to afterDown with a down event.
  • In afterDown we start an internal timer for 500 ms that moves us back to default and emits a hold event. This timer gets discarded if we transition out of that state though.
  • So if an up event comes and we’re in an afterDown state, we transition to afterClick.
  • In afterClick we start another timer, this time for 200 ms, that moves us back to default and emits a click.
  • But If a down event comes before that timer then we transition to default, but with a doubleClick event.

Here's how we could implement it with xstate, a popular state machine library for JavaScript and Typescript:

import { createMachine } from "xstate";

const timer = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export const gesturesMachine = createMachine({
  schema: {
    events: {} as { type: "UP" } | { type: "DOWN" },
  },
  initial: "default",
  states: {
    default: {
      on: {
        DOWN: "afterDown",
      },
    },
    afterDown: {
      on: {
        UP: "afterClick",
      },
      invoke: {
        src: () => timer(500),
        onDone: {
          target: "default",
          actions: () => console.log("hold"),
        },
      },
    },
    afterClick: {
      on: {
        UP: {
          target: "default",
          actions: () => console.log("doubleClick"),
        },
      },
      invoke: {
        src: () => timer(200),
        onDone: {
          target: "default",
          actions: () => console.log("click"),
        },
      },
    },
  },
});

And here is how we could use it in React:

import "./App.css";
import { useMachine } from "@xstate/react";
import { gesturesMachine } from "./stateMachine";

function App() {
  const [_current, send] = useMachine(gesturesMachine);

  return (
    <div className="App">
      <button
        onMouseDown={() => {
          send("DOWN");
        }}
        onMouseUp={() => {
          send("UP");
        }}
      >
        State Machine Button
      </button>
    </div>
  );
}

export default App;

Hey, you made it to the end! Hopefully, you’ve found this quick guide somewhat inspiring. If you have any suggestions or your own experiences with implementing useful streams with RxJS, feel free to share them with us by leaving a comment 🙏🏻

Reviewed by: Łukasz Lenart

Blog Comments powered by Disqus.