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.
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#
npm i @dmytromykhailiuk/preact-signal-utils
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:
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.
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#
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.
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:
const user$ = useSignalProp(user, (a, b) => a.id === b.id);
// a new object with the same id never causes a signal write
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#
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:
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#
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.
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#
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.
useInit(() => {
const id = startPolling();
return () => stopPolling(id);
});
useDestroy#
function useDestroy(fn: () => void): void;
Runs fn once, when the component unmounts — nothing
on mount, nothing on re-renders.
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#
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.
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#
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.
// 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 });
Known names infer the event type
("pointermove" →
PointerEvent); custom events fall back to the
explicit generic: useEvent<CustomEvent>("app:sync", …).
Always the latest closure — passing a new inline function on a re-render never detaches and re-attaches the listener.
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#
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 }.
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#
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.
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#
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.
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
onFinishclosure is always the one called.
waitFor#
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.
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.
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", …)infersKeyboardEventfromWindowEventMap; custom events take an explicit generic. -
Element generics flow through refs:
useElementSizeSignal<HTMLDivElement>()gives aref$that only fits a<div>. -
Outputs are
ReadonlySignals — the compiler stops a consumer from writing tosize$orx$.
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