Skip to content
Chris Michael edited this page Jul 22, 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.
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.

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 scope.addFinalizer for a resource that must survive effect reruns and end only when the hook is disposed. Finalizers execute once in reverse registration order. Every finalizer is attempted; multiple failures are combined and routed through the lifecycle error handler.

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

Related

Clone this wiki locally