Efficient Sidebar Resizing with Preact Signals

30 Sep 2023

Introduction

In my recent tweet, I quickly shared a video demonstrating how I implemented sidebar resizing with Preact in a way that is both straightforward and efficient.

The tweet was then retweeted by Jason Miller, an original author of Preact, and it gained significant traction, and few people also asked for the source-code.

The codebase in the video is not open-source; it’s a personal project I’m currently working on. However, I decided to share a snippet of the resize functionality for your reference.

Please note that the code provided below is by no means a perfect or comprehensive solution. It is simply a copy-paste of what I am currently using, and it only supports horizontal resizing. Nevertheless, it should be relatively easy to adapt, and its simplicity makes it easy to understand, as it does not attempt to address every potential edge case.

Preact Signals: A Quick Overview

Before we dive into the implementation, let’s briefly recap what Preact Signals are.

Signals are like small observables with a getter and setter for their .value property.

You create a signal with signal(123) and whenever you read the .value, the component will subscribe to the signal and it will get re-rendered automatically.

If you use the signal directly in the template or a prop, it has a special treatment in Preact, it just updates the DOM and skips the render call entirely.

Efficient Sidebar Resizing Implementation

So ideally, we want to render the Preact component once, set up listeners, and the logic, and then opt-out from the automatic re-rendering.

We cannot use a computed width in the style prop because that would cause a full re-render on every change of the width.value. Instead, we can create another computed signal to pass directly to the style prop in Preact.

Here’s the full-code:

import { computed } from "@preact/signals";
import { useMemo } from "preact/hooks";

export const useResize = ({
  width,
  minWidth = 0,
  maxWidth = Number.MAX_SAFE_INTEGER,
}) =>
  useMemo(() => {
    const onMouseDown = (e: MouseEvent) => {
      const { pageX: startX } = e;
      const startWidth = width?.value;

      const updater = (e: MouseEvent) =>
        (width.value = Math.max(
          minWidth,
          Math.min(maxWidth, startWidth + e.pageX - startX)
        ));

      // setup listener to compute and update the width
      window.addEventListener("mousemove", updater);

      // setup listener which will remove the update listener
      window.addEventListener(
        "mouseup",
        () => window.removeEventListener("mousemove", updater),
        { once: true }
      );

      // prevent any other interaction during resize
      e.preventDefault();
      e.stopPropagation();
    };

    // this is the trick, computed signal which we can then
    // pass directly to the style prop
    const style = computed(() => `width: ${width.value}px`);

    const resizeHandle = (
      <div
        class="absolute right-0 inset-y-0 w-2 cursor-col-resize"
        onMouseDown={onMouseDown}
      />
    );

    return { style, resizeHandle, onMouseDown };
  }, [width, minWidth, maxWidth]);

And usage could look like this:

export const Sidebar = () => {
  const width = useSignal(200);
  const { style, resizeHandle } = useResize({
    width,
    minWidth: 150,
    maxWidth: 400,
  });

  return (
    // where the CSS would be something like:
    // .sidebar { position: relative; display: flex; flex-direction: column }
    <div class="sidebar" style={style}>
      sidebar content
      {resizeHandle}
    </div>
  );
};

The useResize hook takes in a width signal as a prop and optional minWidth and maxWidth props to define the minimum and maximum width of the sidebar.

When the user clicks and drags the resize handle, the onMouseDown function is called. This function sets up event listeners for mousemove and mouseup events and calculates the new width based on the starting point and the current mouse position.

The width.value is then updated using the updater function, and the sidebar’s style is updated accordingly.