@dmytromykhailiuk/preact-signal-utils
v1.0.0
Published
Utility hooks for @preact/signals — prop/state bridging (useSignalProp, useLinkedSignal, usePrevSignalValue), lifecycle (useInit, useDestroy, useAfterSignalChangeEffect), DOM helpers (useEvent, useElementSizeSignal, useScrollToItem, useTouchMove) and wait
Maintainers
Readme
@dmytromykhailiuk/preact-signal-utils
Utility hooks for @preact/signals — prop/state bridging, lifecycle helpers, DOM observers and
gesture tracking. Signals in, signals out: every hook returns signals you bind directly to JSX,
so components mount once and never re-render.
Full documentation: open Docs in a browser — every hook, with examples, a table of contents and cross-links. This README is the short form.
⚡ The rule that makes it work: never unwrap
.valuein the render path. Bind the signals these hooks return straight to JSX text and attributes, derive withuseComputed, render conditionals with<Show>and lists with<For>(from@preact/signals/utils). Unwrap only insidecomputed/effectcallbacks and event handlers.
Install
npm i @dmytromykhailiuk/preact-signal-utilsPeer dependencies: preact >= 10.11 and @preact/signals ^2.0.0.
Hooks at a glance
| Export | What it does |
| ---------------------------- | ----------------------------------------------------------------------- |
| useSignalProp | Mirrors a plain prop into a stable signal |
| useLinkedSignal | Writable signal that resets whenever its computation changes |
| usePrevSignalValue | The previous value of a signal, as a signal |
| useAfterSignalChangeEffect | useSignalEffect that skips the initial run |
| useInit | Runs a callback once after mount (returned function = cleanup) |
| useDestroy | Runs a callback once on unmount (always the latest closure) |
| useEvent | Declarative addEventListener on window or a signal ref |
| useElementSizeSignal | Element { width, height } as a signal, via ResizeObserver |
| useScrollToItem | "Jump to latest" — IntersectionObserver + smooth scroll |
| useTouchMove | Live drag/swipe deltas (touch and mouse) as signals |
| waitFor | Promise that resolves when a signal predicate becomes true |
Quick start
import { useComputed } from "@preact/signals";
import { useElementSizeSignal, useEvent } from "@dmytromykhailiuk/preact-signal-utils";
function Panel() {
const { ref$, size$ } = useElementSizeSignal<HTMLDivElement>();
const label = useComputed(() => `${size$.value.width} × ${size$.value.height}`);
useEvent("keydown", (event) => {
if (event.key === "Escape") close();
});
// `label` is bound directly — resizes update the text node, never the component
return <div ref={ref$}>{label}</div>;
}Bridging props and state
// a plain prop, usable in the signal world — stable signal, updated in place
const value$ = useSignalProp(props.value);
// derived-but-editable state: resets when the source changes
const draft$ = useLinkedSignal(() => selectedItem$.value.title);
draft$.value = "edited locally"; // kept until selectedItem$ changes
// what was in the signal before its latest change (null until it changes)
const prevStep$ = usePrevSignalValue(step$);Lifecycle & effects
useInit(() => {
const id = startPolling();
return () => stopPolling(id); // cleanup on unmount
});
useDestroy(() => analytics.flush()); // latest closure, unmount only
// fires on every change of theme$ — but not with the value it mounted with
useAfterSignalChangeEffect(theme$, (theme) => applyTheme(theme));DOM hooks
// window listener with the usual options; detaches on unmount
useEvent("resize", onResize, { passive: true });
// or on an element, via a signal ref — attaches when the element appears
const { ref$, size$ } = useElementSizeSignal<HTMLDivElement>();
useEvent("scroll", onScroll, { target: ref$ });
// chats/logs: is the anchor item out of view, and how to get back to it
const { ref$: anchor$, canScroll$, scroll } = useScrollToItem<HTMLLIElement>();
// swipe-to-dismiss: live deltas while dragging, one callback at the end
const { x$ } = useTouchMove(cardRef$, ({ x }) => {
if (Math.abs(x) > 120) dismiss();
});
const transform = useComputed(() => `translateX(${x$.value}px)`);waitFor
import { waitFor } from "@dmytromykhailiuk/preact-signal-utils";
await waitFor(() => session$.value !== null); // resolves on the exact writeNot a hook — works in components, stores and tests alike. If the predicate is already true,
it resolves immediately without creating an effect; otherwise the effect is disposed as soon as
it fires.
TypeScript
Everything is typed end to end: useEvent("keydown", …) infers KeyboardEvent from
WindowEventMap, sizes are ReadonlySignal<ElementSize>, refs are SignalRef<T | null>
(compatible with useSignalRef from @preact/signals/utils and JSX ref props). Exported
types: SignalRef, ElementSize, UseEventOptions, UseElementSizeSignalResult,
UseScrollToItemResult, UseTouchMoveResult, TouchMoveEndEvent.
License
MIT © Dmytro Mykhailiuk
