preact-signal-utils
preact-signal-utils 1.0.0

Utility hooks for @preact/signals — bridging, lifecycle, DOM observers, gestures. Signals in, signals out.

Contents

Contents

preact-signal-utils

Utility hooks for @preact/signals — prop and state bridging, lifecycle helpers, DOM observers and gesture tracking. Signals in, signals out.

Signals promise a simple model: a component mounts once, and every later update flows through signals bound directly to the DOM — no re-renders, no dependency arrays, no memoization ceremony. In practice the promise breaks at the edges: plain props arriving from above, ResizeObserver and event listeners on the DOM side, gestures, "wait until the store is ready" flows. Each of those edges tempts you back into useState and useEffect — and once one value lives outside the signal graph, everything derived from it re-renders again.

This library covers those edges. Every hook takes signals and returns signals — sizes, deltas, flags, previous values — so the reactive graph stays unbroken from the outermost prop to the last text node, and the component function still runs exactly once.

signals in, signals out zero re-render typed end to end lifecycle-safe listeners touch + mouse gestures
Never unwrap .value in render

The hooks keep their side of the bargain by returning signals — the component keeps its side by never reading .value while rendering. Bind the signal itself to JSX text and attributes, derive with useComputed, render conditionals with <Show> and lists with <For> from @preact/signals/utils. Unwrap only inside computed / effect callbacks and event handlers — one .value in the render path quietly subscribes the whole component and brings re-renders back.

Getting started

Install#

sh
npm i @dmytromykhailiuk/preact-signal-utils
Requirements

Peer dependencies: preact >= 10.11 and @preact/signals ^2.0.0. Nothing else — ships ESM and CJS with type declarations for both.

Quick start#

A panel that reports its own size and closes on Escape — without ever re-rendering:

tsx
import { useComputed } from "@preact/signals";
import {
  useElementSizeSignal,
  useEvent,
} from "@dmytromykhailiuk/preact-signal-utils";

function Panel({ onClose }: { onClose: () => void }) {
  const { ref$, size$ } = useElementSizeSignal<HTMLDivElement>();
  const label = useComputed(() => `${size$.value.width} × ${size$.value.height}`);

  useEvent("keydown", (event) => {
    if (event.key === "Escape") onClose(); // typed: KeyboardEvent
  });

  // `label` is bound directly — resizes update the text node, not the component
  return <div ref={ref$}>{label}</div>;
}

Every piece follows the same pattern: the hook owns the messy part (observer lifecycles, listener re-attachment, gesture state) and hands back signals; the component binds them and never runs again.

Signals in, signals out#

The library's one opinion: a value that changes over time should live in a signal — and stay there. Three rules follow, and every hook here obeys them:

  • Hooks return signals, never snapshots. size$, x$, canScroll$, prev$ — all of them are signals you bind or derive from, not values that force a re-render to refresh.
  • Everything returned is reference-stable. The signals, refs and callbacks a hook returns are created once per component — safe to pass to children, effects and dependency arrays without memoization on your side.
  • Cleanup is the hook's job. Observers disconnect, listeners detach, effects dispose — on unmount and on every re-attachment in between. No manual bookkeeping.
The $ suffix

Names ending in $ (ref$, size$, x$) are signals. It's a convention, not a requirement — but it makes the reactive surface of a component visible at a glance, and the docs stick to it throughout.

Reference

Bridging props & state#

Components rarely get to choose their inputs: props arrive as plain values, server state changes under your feet, and some local state is both derived and editable. These three hooks move each of those cases into the signal graph.

useSignalProp#

ts
function useSignalProp<T>(
  value: T,
  compareFn?: (a: T, b: T) => boolean, // default: strict equality
): Signal<T>;

Mirrors a plain prop into a stable signal: the same signal instance on every render, updated in place whenever the prop actually changes. The parent may re-render and pass new values — everything downstream of the signal updates reactively, without re-rendering this component's children.

tsxa signal-world component with a plain-props API
function PriceTag({ amount }: { amount: number }) {
  const amount$ = useSignalProp(amount);
  const formatted = useComputed(() => currency.format(amount$.value));

  return <span>{formatted}</span>;
}

A custom compareFn decides what "changed" means — useful when the parent recreates objects on every render:

ts
const user$ = useSignalProp(user, (a, b) => a.id === b.id);
// a new object with the same id never causes a signal write
No self-subscription

The comparison reads the signal with .peek(), so the component never subscribes to its own prop signal — external writes to it won't re-render the component either.

useLinkedSignal#

ts
function useLinkedSignal<T>(fn: () => T): Signal<T>;

A writable signal that follows a computation. It starts with fn()'s current result and accepts local writes like any signal — but whenever a signal read inside fn changes, the linked signal resets to the fresh result, discarding the local value. Derived-but-editable state in one line:

tsa draft that resets when the selection changes
const title$ = useLinkedSignal(() => selectedItem$.value.title);

title$.value = "edited locally";   // user types — kept
// …selectedItem$ changes →  title$ resets to the new item's title

The reset fires only when the computed result actually changes (===): if the source recomputes to an equal value, local edits survive.

usePrevSignalValue#

ts
function usePrevSignalValue<T>(
  signal$: ReadonlySignal<T>,
): ReadonlySignal<T | null>;

The value a signal held before its latest change, as a read-only signal — null until the first change. Identical writes (===) are ignored, so the previous value only moves when the signal actually changes.

tsxslide direction from the step history
const step$ = useSignalProp(step);
const prevStep$ = usePrevSignalValue(step$);
const direction = useComputed(() =>
  (prevStep$.value ?? 0) <= step$.value ? "forward" : "back",
);

return <div data-direction={direction}>…</div>;

Lifecycle & effects#

Mount and unmount, without the useEffect shibboleths — plus the missing variant of useSignalEffect that skips its initial run.

useInit#

ts
function useInit(fn: () => void | (() => void)): void;

Runs fn once, right after the component mounts. A returned function becomes the cleanup and runs on unmount. It is useEffect(fn, []) with the intent in the name — and no dependency array to get wrong.

ts
useInit(() => {
  const id = startPolling();
  return () => stopPolling(id);
});

useDestroy#

ts
function useDestroy(fn: () => void): void;

Runs fn once, when the component unmounts — nothing on mount, nothing on re-renders.

Always the latest closure

The fn from the most recent render is the one that runs. A naive useEffect(() => () => fn(), []) captures the first render's closure forever; useDestroy tracks it via a ref, so an inline useDestroy(() => save(draft)) saves the current draft, not the mount-time one.

useAfterSignalChangeEffect#

ts
function useAfterSignalChangeEffect<T>(
  signal$: ReadonlySignal<T>,
  effect: (value: T) => void | (() => void),
): void;

useSignalEffect always fires once immediately, with the value the component mounted with. For genuine reactions — "when the theme changes", "when the filter changes" — that first run is noise you'd otherwise guard with a ref by hand. This hook skips it: effect fires only on later changes of signal$, receiving the new value; a returned function is that run's cleanup.

ts
useAfterSignalChangeEffect(filters$, (filters) => {
  // not called with the initial filters — only on real changes
  const controller = new AbortController();
  refetch(filters, controller.signal);
  return () => controller.abort();
});

This is also the primitive behind useLinkedSignal — "follow the source, but don't clobber the initial value".

DOM hooks#

The DOM speaks callbacks — listeners, observers, gestures. These hooks translate it into signals, and take the lifecycle bookkeeping (attach, re-attach, disconnect) with them. All of them work through signal refs: pass the ref$ to a JSX ref prop, and the hook reacts when the element appears, changes or disappears.

useEvent#

ts
function useEvent<K extends keyof WindowEventMap>(
  eventName: K,
  callback: (event: WindowEventMap[K]) => void,
  options?: AddEventListenerOptions & { target?: SignalRef<HTMLElement | null> },
): void;

Declarative addEventListener bound to the component's lifetime. Listens on window by default, or on options.target — a signal ref, so the listener attaches when the element mounts and moves when it changes.

tsx
// window — event type inferred from the name (KeyboardEvent here)
useEvent("keydown", (event) => {
  if (event.key === "Escape") close();
});

// element — waits for the ref, re-attaches if the element changes
const { ref$ } = useElementSizeSignal<HTMLDivElement>();
useEvent("scroll", onScroll, { target: ref$, passive: true });
eventNamekeyof WindowEventMap | string

Known names infer the event type ("pointermove"PointerEvent); custom events fall back to the explicit generic: useEvent<CustomEvent>("app:sync", …).

callback(event) => void

Always the latest closure — passing a new inline function on a re-render never detaches and re-attaches the listener.

optionsAddEventListenerOptions & { target? }

capture, once, passive and signal are forwarded as-is. target is a signal ref (useSignalRef, or any ref$ from this library); while it is empty nothing is attached.

useElementSizeSignal#

ts
function useElementSizeSignal<T extends HTMLElement = HTMLElement>(): {
  ref$: SignalRef<T | null>;
  size$: ReadonlySignal<{ width: number; height: number }>;
};

An element's size as a signal. Attach ref$; from that moment size$ holds offsetWidth/offsetHeight and a ResizeObserver keeps it current — through container resizes, content changes and element swaps. Until the ref is attached the size reads { width: 0, height: 0 }.

tsxcontainer queries, the imperative way out
function Chart() {
  const { ref$, size$ } = useElementSizeSignal<HTMLDivElement>();
  const isNarrow = useComputed(() => size$.value.width < 480);
  const legendClass = useComputed(() =>
    isNarrow.value ? "legend legend--stacked" : "legend",
  );

  return (
    <div ref={ref$}>
      <Plot size$={size$} />
      <div class={legendClass}>…</div>
    </div>
  );
}

useScrollToItem#

ts
function useScrollToItem<T extends HTMLElement = HTMLElement>(
  options?: IntersectionObserverInit, // default: { threshold: 0.6 }
): {
  ref$: SignalRef<T | null>;
  canScroll$: ReadonlySignal<boolean>;
  scroll: () => void;
};

"Jump to latest" for chats, logs and feeds. Attach ref$ to the anchor element — typically the newest item. canScroll$ is true while that element is out of view (tracked by an IntersectionObserver), and scroll() smooth-scrolls it back in.

tsxthe button appears exactly when it is useful
import { Show, For } from "@preact/signals/utils";

function Chat({ messages$ }: { messages$: Signal<Message[]> }) {
  const { ref$, canScroll$, scroll } = useScrollToItem<HTMLLIElement>();

  return (
    <>
      <ul>
        <For each={messages$}>{(m) => <li>{m.text}</li>}</For>
        <li ref={ref$} /> {/* the anchor rides at the end of the list */}
      </ul>
      <Show when={canScroll$}>
        <button onClick={scroll}>↓ New messages</button>
      </Show>
    </>
  );
}

With several batched observer entries the most recent one wins; scroll() is a no-op while the ref is empty. Pass your own IntersectionObserverInit to change the root, margins or threshold.

useTouchMove#

ts
function useTouchMove<T extends HTMLElement>(
  ref$: SignalRef<T | null>,
  onFinish: (event: { x: number; y: number; time: number }) => void,
): {
  x$: ReadonlySignal<number>;
  y$: ReadonlySignal<number>;
};

Drag tracking — touch and mouse — on the element behind ref$. While the pointer moves, x$ / y$ hold the live delta from the gesture's start; when it ends, onFinish receives the final delta plus the duration, and the signals reset to 0.

tsxswipe-to-dismiss: live transform, one decision at the end
function Card({ onDismiss }: { onDismiss: () => void }) {
  const ref$ = useSignalRef<HTMLDivElement | null>(null);
  const { x$ } = useTouchMove(ref$, ({ x, time }) => {
    const fastFling = Math.abs(x) / time > 0.5;
    if (Math.abs(x) > 120 || fastFling) onDismiss();
  });
  const style = useComputed(() => `transform: translateX(${x$.value}px)`);

  return <div ref={ref$} style={style}>…</div>;
}
  • Touch listeners are registered passive — tracking never blocks native scrolling.
  • Mouse moves are captured on document, so a drag survives the pointer leaving the element.
  • Multi-touch starts are ignored; pinch gestures stay untouched.
  • The latest onFinish closure is always the one called.

waitFor#

ts
function waitFor(signalPredicate: () => boolean): Promise<void>;

The bridge from the signal world to async/await: a promise that resolves once the predicate returns true. The predicate runs inside a signal effect, so every signal it reads is tracked — the promise settles on the exact write that flips the condition, and the effect is disposed immediately after. No polling, no timeouts.

tsnot a hook — works in stores, flows and tests alike
import { waitFor } from "@dmytromykhailiuk/preact-signal-utils";

async function checkout() {
  await waitFor(() => session$.value !== null);
  await waitFor(() => cart$.value.items.length > 0);
  submitOrder(session$.peek(), cart$.peek());
}

If the predicate is already true, the promise resolves immediately and no effect is ever created. Predicates reading several signals work naturally — the condition is re-evaluated whenever any of them changes.

The predicate must read signals

Tracking comes from the signals read during the call. A predicate over non-signal state — () => window.innerWidth > 800 — is only evaluated once and will never wake up again. Put the changing value in a signal first.

TypeScript#

Everything is typed end to end — no any at the boundaries, no casts in your code:

  • useEvent("keydown", …) infers KeyboardEvent from WindowEventMap; custom events take an explicit generic.
  • Element generics flow through refs: useElementSizeSignal<HTMLDivElement>() gives a ref$ that only fits a <div>.
  • Outputs are ReadonlySignals — the compiler stops a consumer from writing to size$ or x$.
ts
import type {
  SignalRef,     // ReadonlySignal<T> & { current: T } — what useSignalRef returns
  ElementSize,   // { width: number; height: number }
} from "@dmytromykhailiuk/preact-signal-utils";

// SignalRef works for JSX refs and for hook targets alike:
const ref$ = useSignalRef<HTMLDivElement | null>(null);
useEvent("pointerdown", onDown, { target: ref$ }); // ✓
useTouchMove(ref$, onFinish);                      // ✓ same ref, both hooks

Exports#

Hooks

useSignalProp · useLinkedSignal · usePrevSignalValue · useAfterSignalChangeEffect · useInit · useDestroy · useEvent · useElementSizeSignal · useScrollToItem · useTouchMove

Utilities

waitFor

Types

SignalRef · ElementSize · UseEventOptions · UseElementSizeSignalResult · UseScrollToItemResult · UseTouchMoveResult · TouchMoveEndEvent