-
-
Notifications
You must be signed in to change notification settings - Fork 0
Hooks
defineHook({ name?, layers?, setup })Status: Current.
Hooks package reusable reactive logic. Component and hook layer access uses the same alias-record model.
export const useCurrentUser = defineHook({
name: 'useCurrentUser',
layers: { auth: AuthLayer } as const,
setup({ computed, layers }) {
const auth = layers.auth.service('auth');
return {
label: computed(() => auth.currentUser().name),
};
},
});The layer must already be registered at the application composition root. Missing bindings fail before hook setup executes.
| Member | Purpose |
|---|---|
config |
Typed input supplied by the caller. |
signal, computed
|
Reactive state and derivation. |
watchEffect |
Lifecycle-owned reactive effect with rerun cleanup and an EffectHandle. |
onMount |
Register work with an active component lifecycle. |
onCleanup |
Register a hook-lifetime finalizer without reaching through the advanced scope API. |
abortSignal |
Native signal aborted when this hook invocation begins disposal. |
layers |
Typed alias or list accessor. |
scope |
Register LIFO finalizers or manually dispose a standalone hook. |
use |
Compose another hook function. |
runAsync |
Execute typed asynchronous work with the invocation's AbortSignal. |
const useThreshold = defineHook<
{ minimum: number },
{ accepted: (value: number) => boolean }
>({
setup({ config }) {
return { accepted: (value) => value >= config.minimum };
},
});Use Config | undefined when a hook has optional configuration. defineHook
then exposes an optional call instead of forcing callers to pass undefined:
const useFeatureFlag = defineHook<
{ initialValue: boolean } | undefined,
ReadonlySignal<boolean>
>({
setup({ config, signal }) {
return signal(config?.initialValue ?? false);
},
});
useFeatureFlag();
useFeatureFlag({ initialValue: true });Hooks called during component setup are owned by that component lifecycle.
Unmount stops every hook effect synchronously before asynchronous scope
finalizers run. A cleanup returned from watchEffect runs before the next
reactive execution and again when the effect stops:
const useWindowEvents = defineHook({
setup({ watchEffect }) {
const effect = watchEffect(() => {
const onResize = () => console.log(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
});
return { pause: effect.pause, resume: effect.resume };
},
});Use onCleanup for a resource that must survive effect reruns and end only when
the hook is disposed. scope.addFinalizer remains available for advanced manual
ownership. Finalizers execute once in reverse registration order. Every
finalizer is attempted; multiple failures are combined and routed through the
lifecycle error handler.
Each hook invocation owns a distinct native abort signal. Effuse aborts it synchronously when disposal begins, before user finalizers run:
const useUserProfile = defineHook<
{ userId: string },
{ profile: ReadonlySignal<UserProfile | null> }
>({
setup({ abortSignal, config, onCleanup, runAsync, signal }) {
const profile = signal<UserProfile | null>(null);
const connection = openProfileChannel(config.userId);
onCleanup(() => connection.close());
void runAsync(async (ownedSignal) => {
const response = await fetch(`/api/users/${config.userId}`, {
signal: ownedSignal,
});
if (!abortSignal.aborted) profile.value = await response.json();
});
return { profile };
},
});Cancellation is cooperative. Effuse aborts the signal but does not wait for or
claim to forcibly cancel a Promise whose implementation ignores it. This keeps
teardown bounded while allowing fetch and other Web APIs to stop promptly.
Calling runAsync after disposal rejects with the native abort reason.
Zero-argument callbacks remain source-compatible when no signal is needed.
When a hook is invoked without an active component lifecycle, onMount runs
immediately and its cleanup belongs to the hook scope. A standalone hook must
expose or otherwise arrange scope.dispose() if it allocates long-lived
resources. Component-owned hooks need no manual disposal.
If layer validation or hook setup throws, Effuse stops effects immediately, aborts owned asynchronous work, starts best-effort scope cleanup, preserves the original setup error, and reports any asynchronous rollback failure as a lifecycle cleanup error.
@effuse/use provides browser-focused hooks for storage, media queries, online
state, intervals, debounce/throttle, event listeners, element visibility,
window size, and related concerns. Browser-only hooks must retain explicit SSR
guards.
Debounce and throttle preserve the source signal value type, including unions, readonly values, and object shapes. Event listeners derive valid event names and callback payloads from their target:
useEventListener({
target: document,
event: 'visibilitychange',
handler: (event) => console.log(event.type), // Event
});
useEventListener({
target: button,
event: 'click',
handler: (event) => console.log(event.clientX), // MouseEvent
});Known DOM targets reject incompatible event names during type checking. Custom
EventTarget implementations accept application-defined string event names
and receive Event.
Effect may power internal hook implementation, but it is not part of the
@effuse/use developer contract. Published ESM and CommonJS declarations use
Effuse-owned tagged unions, constructors, matchers, and native Error shapes;
they contain no imports from Effect. A package build gate enforces this
boundary. Developers can construct and narrow states, use $is and $match,
and catch exported errors without importing or learning Effect.
The first production browser-utility set shares defineHook lifecycle
ownership, explicit SSR state, typed failures, tracing categories, and
Effect-free declarations.
| Hook | Production contract |
|---|---|
useTimeout |
Deadline-based remaining time, pause/resume, restart, cancel, exact-once completion, and callback error state. |
useDocumentVisibility |
visible, hidden, or explicit unknown state with mount synchronization and listener cleanup. |
useClipboard |
Independent read/write capabilities, best-effort permissions, non-rejecting operations, and race-safe transient copied state. |
usePreferredColorScheme |
Separate dark/light queries preserving no-preference, with explicit SSR and unsupported state. |
const timeout = useTimeout({
delay: 5_000,
immediate: false,
callback: () => dismissNotice(),
});
timeout.start(); // Start or resume a paused timeout.
timeout.pause();
timeout.restart(); // Reset to the full configured delay.
timeout.cancel(); // Clear work and return to idle.Remaining time is calculated from an absolute deadline rather than assumed timer intervals, so background throttling does not accumulate arithmetic drift. Automatic start occurs on mount. Timers cannot fire after unmount.
const visibility = useDocumentVisibility();
const colors = usePreferredColorScheme();Both hooks default to unknown during SSR and before mount. Applications with
a deliberate hydration assumption can pass ssrState or ssrScheme.
Unsupported clients return unknown with isSupported set to false.
usePreferredColorScheme returns light, dark, no-preference, or
unknown. A false dark query is not treated as proof of light preference.
const clipboard = useClipboard({ copiedDuration: 2_000 });
const copied = await clipboard.copy('Effuse');
const text = await clipboard.read();canRead and canWrite are independent. Permission querying is best-effort
because browser support differs. Unsupported APIs, denied permissions, and
read/write failures resolve to false or null and populate typed
ClipboardError state; expected capability failures do not reject into
component code. Late asynchronous results, permission listeners, and copied
timers are discarded on unmount.
useInterval exposes a readonly status signal ('running', 'paused', or
'stopped') plus derived isRunning, isPaused, and isStopped signals, all
computed directly from one internal tagged state:
const interval = useInterval({ callback: tick, delay: 1_000 });
interval.pause();
interval.status.value; // 'paused' — resumable, count retained
interval.start(); // resumes with the retained count
interval.stop(); // resets count and enters 'stopped'pause() transitions only from running, so a stopped interval can never
report paused — paused always means resumable. start() from stopped
restarts the count at zero. During SSR and before mount the interval reports
stopped; no browser work happens at creation.
useAsyncTask is the command-driven asynchronous primitive in @effuse/use.
It owns loading, latest-wins replacement, cancellation, and unmount suppression
so features do not reimplement those guards:
const saveUser = useAsyncTask({
task: (signal, input: UserInput) => api.saveUser(input, { signal }),
});
const result = await saveUser.execute(input); // UserProfile | undefined
saveUser.cancel();
saveUser.reset();Generic argument tuples and the task result infer without casts. The hook
returns readonly status (idle, pending, success, error,
cancelled), data, error, and derived isPending, isSuccess,
isError, and isCancelled signals.
- Each
executecall aborts the prior run and becomes the sole state owner. A stale run may settle, but it cannot overwrite newer state. - Explicit
cancelresolves the pendingexecutePromise asundefinedand enterscancelledonly while the hook is mounted and the run is current. - Unexpected task failures populate the
unknownerror state and reject the caller Promise. An error is treated as cancellation only when the hook's ownedAbortSignalactually aborted — an arbitrary error namedAbortErroris a real failure. -
resetaborts active work and restores idle state with the configuredinitialData. - Hook teardown aborts the active run; late results cannot mutate signals and teardown introduces no unhandled rejection.
- Creation performs no browser work, so the hook is SSR-safe.
Task identity is not reactive: an invocation snapshots its configuration, and a new hook invocation owns a new task. Caching, retries, deduplication, and server-state ownership belong to query-layer packages, not this command hook.