DEV Community

devphilip21
devphilip21

Posted on

I mass-deleted my event handlers and replaced them with streams. No regrets.

I've been wrestling with gesture handling in frontend projects for years. Pan, pinch, keyboard combos, wheel zoom—every time I implemented these, I ended up with tangled event listeners and duplicated logic everywhere.

So I tried a different architectural approach: treating all user inputs as observable streams that can be composed with operators.

The result is Cereb, a lightweight library I've been building. I want to share the pattern and see what you think.

The Problem We Keep Writing

Here's code I'm sure many of you recognize—implementing zoom with multiple input methods:

// The classic addEventListener tangle
let currentScale = 1;
let isZoomMode = false;
let lastTouchDistance = 0;

window.addEventListener('keydown', (e) => { /* check for 'z' key... */ });
window.addEventListener('keyup', (e) => { /* uncheck for 'z' key... */ });
canvas.addEventListener('wheel', (e) => {
  if (!isZoomMode) return;
  currentScale = Math.min(3, Math.max(0.5, currentScale + e.deltaY * -0.01));
  // this min/max logic is going to haunt me...
});
canvas.addEventListener('touchstart', (e) => { /* calculate initial distance... */ });
canvas.addEventListener('touchmove', (e) => {
  // yep, duplicating the clamping again
  currentScale = Math.min(3, Math.max(0.5, newScale));
});
Enter fullscreen mode Exit fullscreen mode

8+ handlers. Shared mutable state. The same min/max clamping copy-pasted everywhere.

The moment you need to add gamepad support or new keyboard shortcuts, it becomes whack-a-mole.

What If Inputs Were Streams?

What if each input source was a stream, and we could pipe them through shared operators?

import { wheel, keydown, keyheld } from "cereb";
import { when, extend } from "cereb/operators";
import { pinch } from "@cereb/pinch";

const zoomMode$ = keyheld(window, { code: "KeyZ" });
const zoom = createZoom({ minScale: 0.5, maxScale: 3.0 });

// Pinch zoom
pinch(canvas).pipe(zoom).on(applyScale);

// Z + wheel zoom
wheel(canvas, { passive: false })
  .pipe(when(zoomMode$), extend(e => ({ delta: e.deltaY * -0.01 })), zoom)
  .on(applyScale);

// Z + keyboard zoom
keydown(window, { code: ["Equal", "Minus"] })
  .pipe(when(zoomMode$), extend(keyToZoom), zoom)
  .on(applyScale);
Enter fullscreen mode Exit fullscreen mode

Three input sources. One shared zoom operator. One callback.

Adding a new input method = pipe another stream through the same operators. No duplication.

The Three Primitives

The library is built on three concepts:

1. Signal

Immutable event objects with kind, value, deviceId, and timestamps.

{
  kind: "single-pointer",
  value: { phase: "move", x: 150, y: 200, pressure: 0.5 },
  deviceId: "pointer-1",
  timestamp: 1703123456789
}
Enter fullscreen mode Exit fullscreen mode

Immutability prevents accidental side-effects when the same signal passes through multiple operators.

2. Stream

Lazy observable sequences. Nothing happens until you call .on().

const pointer$ = singlePointer(element);

// Nothing is listening yet...

pointer$.on((signal) => {
  // Now we're subscribed
  console.log(signal.value.x, signal.value.y);
});
Enter fullscreen mode Exit fullscreen mode

Unicast by default (one subscriber). Use share() for multicast. Built-in block()/unblock() for flow control.

3. Operator

Pure functions that transform streams:

pointer$
  .pipe(
    filter(s => s.value.phase === "move"),  // Only moves
    throttle(16),                            // 60fps cap
    offset({ target: element }),             // Add element-relative coords
    map(s => ({ x: s.value.offsetX, y: s.value.offsetY }))
  )
  .on(updatePosition);
Enter fullscreen mode Exit fullscreen mode

Available operators: filter, map, throttle, debounce, session, when, merge, extend, zoom, offset, and more.

Real Example: Multi-Input Canvas

I built a demo to stress-test this pattern—an interactive space scene with parallax stars and orbiting planets. You can zoom with:

  • 🤏 Pinch gestures (touch)
  • 🖱️ Z + scroll wheel
  • ⌨️ Z + plus/minus keys
  • 📊 Slider control

All four inputs orchestrated through the same zoom pipeline.

Try the live demo →

Full source is in the repo under docs/src/components/examples/.

Standing on Giants' Shoulders

Credit where it's due:

Hammer.js taught the web how to handle touch gestures. I've used it in production for years. But its architecture makes it hard to compose with other input sources or extend with custom behaviors.

RxJS is the gold standard for reactive programming. The Observable pattern is brilliant. But for high-frequency gesture handling (60fps+), I found myself fighting its general-purpose nature—too much overhead, too many operators I didn't need.

I wanted Hammer's gesture intelligence with RxJS's compositional model, but optimized specifically for user input.

Bundle Size

Library Gzipped
Hammer.js 7.52 KB
cereb + @cereb/pan 1.73 KB

77% smaller for equivalent pan functionality. Everything is tree-shakeable—import only what you use.

Future Direction

The interesting frontier is input diversity. VR controllers, AR hand tracking, eye gaze, voice—every new input modality currently means learning a new API and gluing it to existing code.

I'm exploring whether this stream-based abstraction can become a universal layer for HCI inputs. Write gesture recognition once, have it work across input modalities.

Get Involved

I'm curious how you handle complex multi-input scenarios. Do you use a gesture library, or roll your own? Let me know in the comments!

Top comments (0)