Skip to content
Chris Michael edited this page Jul 23, 2026 · 8 revisions

Effuse

Hooks

defineHook({ name?, layers?, setup })

Status: Current.

Hooks package reusable reactive logic. Component and hook layer access uses the same alias-record model.

Typed Hook

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.

Hook Context

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.

Configuration

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 });

Cleanup

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.

Ecosystem Hooks

@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.

Public Type Boundary

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.

Production Browser Utilities

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.

Timeout

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.

Visibility And Color Preference

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.

Clipboard

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.

Interval Status

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.

Async Tasks

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.

Ownership Semantics

  • Each execute call aborts the prior run and becomes the sole state owner. A stale run may settle, but it cannot overwrite newer state.
  • Explicit cancel resolves the pending execute Promise as undefined and enters cancelled only while the hook is mounted and the run is current.
  • Unexpected task failures populate the unknown error state and reject the caller Promise. An error is treated as cancellation only when the hook's owned AbortSignal actually aborted — an arbitrary error named AbortError is a real failure.
  • reset aborts active work and restores idle state with the configured initialData.
  • 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.

Related

Clone this wiki locally