@llui/effects
Current package version: 0.3.0
Effect builders for LLui. Effects are data -- update() returns them, the runtime dispatches.
pnpm add @llui/effects
Usage
import { http, cancel, debounce, handleEffects } from '@llui/effects'
// Debounced search with cancel
function update(state: State, msg: Msg): [State, Effect[]] {
switch (msg.type) {
case 'search':
return [
{ ...state, query: msg.value },
[
cancel('search'),
debounce(
'search',
300,
http({
url: `/api/search?q=${msg.value}`,
onSuccess: (data) => ({ type: 'results', data }),
onError: (err) => ({ type: 'searchError', err }),
}),
),
],
]
}
}
// Wire up in component
const handler = handleEffects<Effect, Msg>()
.use(httpPlugin)
.else((effect, send) => {
/* custom effects */
})
API
Effect Builders
| Function | Description |
|---|---|
http({ url, onSuccess, onError }) |
HTTP request effect |
cancel(token, inner?) |
Cancel by token, optionally replace with inner |
debounce(key, ms, inner) |
Debounce inner effect by key |
timeout(ms, msg) |
Fire msg after delay |
interval(ms, msg) |
Fire msg on interval |
storageSet(key, value, storage?) |
Write to localStorage/sessionStorage |
storageGet(key, onResult, storage?) |
Read from storage |
storageRemove(key, storage?) |
Remove from storage |
storageWatch(key, onChange) |
Watch storage for changes |
broadcast(channel, data) |
Send on BroadcastChannel |
broadcastListen(channel, onMsg) |
Listen on BroadcastChannel |
sequence([...effects]) |
Run effects in order |
race([...effects]) |
Run effects concurrently, first wins |
upload({ url, body, onProgress, onSuccess, onError }) |
File upload with progress via XHR |
clipboardRead({ onSuccess, onError }) |
Read text from clipboard |
clipboardWrite(text) |
Write text to clipboard (fire-and-forget) |
notification(title, opts?) |
Show browser notification (requests permission) |
geolocation({ onSuccess, onError, enableHighAccuracy? }) |
One-shot geolocation position |
Upload
Upload files with progress tracking via XMLHttpRequest:
import { upload } from '@llui/effects'
const effect = upload({
url: '/api/upload',
body: formData,
headers: { Authorization: `Bearer ${token}` },
onProgress: (loaded, total) => ({
type: 'uploadProgress',
pct: Math.round((loaded / total) * 100),
}),
onSuccess: (data, status) => ({ type: 'uploadDone', data, status }),
onError: (error) => ({ type: 'uploadFailed', error }),
})
Clipboard
Read and write text via the Clipboard API:
import { clipboardRead, clipboardWrite } from '@llui/effects'
// Copy text to clipboard (fire-and-forget)
clipboardWrite('Hello, world!')
// Read text from clipboard
clipboardRead({
onSuccess: (text) => ({ type: 'pasted', text }),
onError: (error) => ({ type: 'clipError', error }),
})
Notification
Show browser notifications (requests permission automatically):
import { notification } from '@llui/effects'
notification('New message', {
body: 'You have a new message from Alice',
icon: '/avatar.png',
onClick: () => ({ type: 'openChat' }),
onError: () => ({ type: 'notifBlocked' }),
})
Geolocation
One-shot position request:
import { geolocation } from '@llui/effects'
geolocation({
enableHighAccuracy: true,
onSuccess: (pos) => ({
type: 'located',
lat: pos.latitude,
lng: pos.longitude,
}),
onError: (error) => ({ type: 'geoError', error }),
})
Effect Handling
| Function | Description |
|---|---|
handleEffects<E, M>() |
Chainable effect handler builder |
.use(plugin) |
Add an effect handler plugin |
.else(handler) |
Fallback for unhandled effects |
resolveEffects(def) |
SSR data loading -- resolves effects server-side |
Types
| Type | Description |
|---|---|
Async<T, E> |
idle | loading | success | failure -- async data state |
ApiError |
network | timeout | notfound | unauthorized | forbidden | ratelimit | validation | server |
Functions
_getEffectInterceptor()
@internal consumed by @llui/dom's effect-dispatch wrapper.
function _getEffectInterceptor(): EffectInterceptor
_setEffectInterceptor()
Dev-only hook reserved for Phase 2 use. No-op in production — setting
this is a developer opt-in. When null, callers skip the check entirely
so there is zero allocation on the hot path.
Phase 1 reality: @llui/dom's dev effect-dispatch wrapper
(dispatchEffectDev) catches every update-loop effect upstream, so
Phase 1 callers of this hook will NOT observe invocations. Third-party
effect libraries must not rely on this hook being called during Phase 1.
Phase 2 wires this for off-loop dispatches (e.g., effects dispatched
from Web Workers or post-mount lifecycle hooks) where @llui/dom's
wrapper doesn't reach.
function _setEffectInterceptor(hook: EffectInterceptor): void
asOnEffect()
Adapt a handleEffects() chain (the (ctx) => void returned by .else()) to
the signal-runtime onEffect shape: (effect, api) => cleanup.
The signal runtime now hands onEffect a per-mount api.signal (an
AbortSignal aborted exactly once, on THIS mount's dispose()). When present,
this adapter passes that signal straight through to the chain: every mount owns
a distinct signal, so the chain keys its per-mount registries off it and two
concurrent mounts of one definition never interfere. Teardown is driven by the
runtime aborting api.signal, so the returned cleanup is a no-op — the chain's
own abort listener clears the mount's pending http / debounce / interval /
websocket resources. We must NOT abort api.signal ourselves (it is the
runtime's, shared with everything else on the mount).
FALLBACK: when no api.signal is supplied (a bare unit test, or a non-signal
caller), the adapter owns one AbortController PER MOUNT, keyed off the mount's
send identity in a WeakMap. The runtime passes ONE stable send per mount
for every effect it emits, so all of a mount's effects share that mount's
controller — and two CONCURRENT mounts of the same definition get DISTINCT
controllers (distinct sends). That isolation is the point: disposing mount A
must never abort mount B's in-flight http. Controllers are created lazily —
never at factory-call time, since asOnEffect typically runs at module
top-level where constructing an AbortController throws on Cloudflare Workers —
and recreated if a stale (aborted) one is found under a reused send, so a
re-mount never inherits a dead signal. The returned cleanup captures the
controller live at ITS dispatch, so a late unmount of one mount never tears
down a later mount's effects.
Usage: onEffect: asOnEffect(handleEffects<E, M>().use(…).else(…)).
function asOnEffect<E extends { type: string }, M>(chain: (ctx: EffectCtx<E, M>) => void): (effect: E, api: { send: (msg: M) => void; signal?: AbortSignal }) => () => void
broadcast()
function broadcast(channel: string, data: unknown): BroadcastEffect
broadcastListen()
function broadcastListen<M>(channel: string, onMessage: (data: unknown) => M): BroadcastListenEffect<M>
buildRequest()
Build the RequestInit (method + body + content-type headers) for an http
effect, WITHOUT a signal. Shared by the live runHttp and the SSR
resolveEffects so both derive identical requests. Callers add signal
(and any timeout) themselves.
@internal
function buildRequest(effect: HttpEffect): RequestInit
cancel()
export function cancel(token: string): CancelEffect
export function cancel(token: string, inner: BuiltinEffect): CancelReplaceEffect
clipboardRead()
function clipboardRead<M>(opts: {
onSuccess: (text: string) => M
onError: (error: string) => M
}): ClipboardReadEffect<M>
clipboardWrite()
function clipboardWrite(text: string): ClipboardWriteEffect
debounce()
function debounce(key: string, ms: number, inner: BuiltinEffect): DebounceEffect
geolocation()
function geolocation<M>(opts: {
onSuccess: (position: { latitude: number; longitude: number; accuracy: number }) => M
onError: (error: string) => M
enableHighAccuracy?: boolean
}): GeolocationEffect<M>
handleEffects()
Batteries-included handler chain — handles every built-in effect out of the box. See {@link handleEffectsWith} for the tree-shakeable, hand-picked-runner form.
function handleEffects<E extends { type: string }, M = never>(): EffectChain<E, M>
handleEffectsWith()
Build a handler chain over an explicit set of runners. handleEffects() is
this with the batteries-included {@link defaultRunners}; pass a hand-picked
subset here to tree-shake unused runner code out of the bundle.
Per-mount registries are keyed off each mount's lifecycle AbortSignal (a
WeakMap so a torn-down mount's registry is collectible once its signal is
unreachable). One registry is created lazily per distinct signal — i.e. per
mount — and torn down exactly once when that signal aborts. Keying off the
signal (rather than a chain-level closure) keeps two concurrent mounts of the
same component isolated: disposing one never cancels the other's in-flight
http / intervals / debounces / websockets.
function handleEffectsWith<E extends { type: string }, M = never>(runners: readonly Runner[]): EffectChain<E, M>
http()
function http<M>(opts: {
url: string
method?: string
body?: unknown
contentType?: string
headers?: Record<string, string>
timeout?: number
responseType?: 'json' | 'text' | 'blob' | 'arrayBuffer'
onSuccess: (data: unknown, headers: Headers) => M
onError: (error: ApiError) => M
}): HttpEffect<M>
httpStatusToApiError()
function httpStatusToApiError(res: Response): Promise<ApiError>
interval()
function interval<M>(key: string, ms: number, msg: M): IntervalEffect<M>
log()
Log to the console as an effect. Replaces the old core log effect.
function log(message: string, opts?: { level?: LogEffect['level']; data?: unknown }): LogEffect
notification()
function notification<M>(title: string, opts?: {
body?: string
icon?: string
tag?: string
onClick?: () => M
onClose?: () => M
onError?: () => M
}): NotificationEffect<M>
parseResponse()
Parse a response body by explicit responseType, else auto-detect from the
content-type header. Shared by runHttp and resolveEffects.
@internal
function parseResponse(res: Response, responseType?: 'json' | 'text' | 'blob' | 'arrayBuffer'): Promise<unknown>
race()
function race(effects: BuiltinEffect[]): RaceEffect
resolveEffects()
Execute all HTTP effects reachable from the effect list, apply the resulting messages to state via update(), and return the final loaded state.
Http effects nested inside composite builtins (sequence/race/retry/
cancel/debounce) are unwrapped recursively — a sequence([http(...)])
pre-resolves on the server just like a bare http(...). Top-level effects run
in parallel; a sequence's inner effects run in order; messages are applied in
effect order. Recurses if the responses produce more effects (up to a depth limit).
function resolveEffects<S, M extends { type: string }, E extends { type: string }>(state: S, effects: E[], update: UpdateFn<S, M, E>, maxDepth = 3): Promise<S>
retry()
function retry(inner: HttpEffect, opts: {
maxAttempts: number
delayMs: number
/**
* Predicate deciding whether a failure is retriable. Defaults to retrying
* only transient errors (`network`/`timeout`/`ratelimit`/5xx `server`); a
* `401`/`403`/`404`/`validation` error is NOT retried. See {@link RetryEffect.retryOn}.
*/
retryOn?: (error: ApiError, attempt: number) => boolean
}): RetryEffect
sequence()
function sequence(effects: BuiltinEffect[]): SequenceEffect
storageGet()
function storageGet<M>(key: string, onLoad: (value: unknown) => M, scope: StorageScope = 'local'): StorageGetEffect<M>
storageLoad()
Synchronous read from storage. Use at init time to seed state. Returns null on miss or invalid JSON.
function storageLoad<T = unknown>(key: string, scope: StorageScope = 'local'): T | null
storageRemove()
function storageRemove(key: string, scope: StorageScope = 'local'): StorageRemoveEffect
storageSet()
function storageSet(key: string, value: unknown, scope: StorageScope = 'local'): StorageSetEffect
storageWatch()
function storageWatch<M>(key: string, onChange: (value: unknown) => M, scope: StorageScope = 'local'): StorageWatchEffect<M>
timeout()
function timeout<M>(ms: number, msg: M): TimeoutEffect<M>
upload()
function upload<M>(opts: {
url: string
method?: string
body: FormData | Blob
headers?: Record<string, string>
timeout?: number
onProgress: (loaded: number, total: number) => M
onSuccess: (data: unknown, status: number) => M
onError: (error: ApiError) => M
}): UploadEffect<M>
websocket()
function websocket<M>(opts: {
url: string
key: string
protocols?: string[]
onOpen?: () => M
onMessage: (data: unknown) => M
onClose?: (code: number, reason: string) => M
onError?: () => M
}): WebSocketEffect<M>
wsSend()
function wsSend(key: string, data: unknown): WebSocketSendEffect
Types
ApiError
Standard API error type produced by the http() effect.
export type ApiError =
| { kind: 'network'; message: string }
| { kind: 'parse'; message: string }
| { kind: 'timeout' }
| { kind: 'notfound' }
| { kind: 'unauthorized' }
| { kind: 'forbidden' }
| { kind: 'ratelimit'; retryAfter?: number }
| { kind: 'validation'; fields: Record<string, string[]> }
| { kind: 'server'; status: number; message: string }
Async
Models the lifecycle of an async operation.
export type Async<T, E> =
| { type: 'idle' }
| { type: 'loading'; stale?: T }
| { type: 'success'; data: T }
| { type: 'failure'; error: E }
Effect
The union of every builtin effect, parameterized by the component message type
M the effects dispatch. Threading M through the union (and through the
composite wrappers sequence/race/retry/debounce/cancel(inner)) lets the
SSR resolver discriminate each effect and type its produced messages as M
without a cast. M defaults to unknown, so bare BuiltinEffect/Effect
references keep working unchanged.
export type BuiltinEffect<M = unknown> =
| HttpEffect<M>
| CancelEffect
| CancelReplaceEffect<M>
| DebounceEffect<M>
| TimeoutEffect<M>
| IntervalEffect<M>
| LogEffect
| StorageSetEffect
| StorageRemoveEffect
| StorageGetEffect<M>
| StorageWatchEffect<M>
| BroadcastEffect
| BroadcastListenEffect<M>
| SequenceEffect<M>
| RaceEffect<M>
| WebSocketEffect<M>
| WebSocketSendEffect
| RetryEffect<M>
| UploadEffect<M>
| ClipboardReadEffect<M>
| ClipboardWriteEffect
| NotificationEffect<M>
| GeolocationEffect<M>
EffectInterceptor
export type EffectInterceptor = ((effect: unknown, id: string) => EffectInterceptorResult) | null
EffectInterceptorResult
Dev-only effect interceptor hook — consumed by @llui/mcp (via
@llui/dom's devtools wiring) to implement effect mocking.
Contract:
- Default state is
null— zero overhead when no interceptor is set. - Calling
_setEffectInterceptor(null)clears the hook. - The hook receives the raw effect object and an opaque dispatch ID;
it returns either
{ mocked: true, response }to short-circuit the real effect dispatch, or{ mocked: false }to pass through.
Phase 1 consumers rely on the pass-through path; the short-circuit
path is exercised end-to-end through @llui/dom's effect-dispatch
wrapper. This module only owns the null-safe set/get contract.
export type EffectInterceptorResult = { mocked: true; response: unknown } | { mocked: false }
EffectPlugin
Plugin handler — returns true if the effect was handled, false to pass through.
export type EffectPlugin<E, M> = (ctx: EffectCtx<E, M>) => boolean
StorageScope
export type StorageScope = 'local' | 'session'
Interfaces
BroadcastEffect
Post a message to a BroadcastChannel. Fire-and-forget.
export interface BroadcastEffect {
type: 'broadcast'
channel: string
data: unknown
}
BroadcastListenEffect
Subscribe to a BroadcastChannel. Fires the message returned by onMessage(data) per incoming message.
export interface BroadcastListenEffect<M = unknown> {
type: 'broadcast-listen'
channel: string
onMessage: (data: unknown) => M
}
CancelEffect
export interface CancelEffect {
type: 'cancel'
token: string
}
CancelReplaceEffect
export interface CancelReplaceEffect<M = unknown> {
type: 'cancel'
token: string
inner: BuiltinEffect<M>
}
ClipboardReadEffect
export interface ClipboardReadEffect<M = unknown> {
type: 'clipboard-read'
onSuccess: (text: string) => M
onError: (error: string) => M
}
ClipboardWriteEffect
export interface ClipboardWriteEffect {
type: 'clipboard-write'
text: string
}
DebounceEffect
export interface DebounceEffect<M = unknown> {
type: 'debounce'
key: string
ms: number
inner: BuiltinEffect<M>
}
EffectCtx
export interface EffectCtx<E, M> {
effect: E
send: (msg: M) => void
signal: AbortSignal
}
GeolocationEffect
export interface GeolocationEffect<M = unknown> {
type: 'geolocation'
onSuccess: (position: { latitude: number; longitude: number; accuracy: number }) => M
onError: (error: string) => M
enableHighAccuracy?: boolean
}
HttpEffect
export interface HttpEffect<M = unknown> {
type: 'http'
url: string
method?: string
body?: unknown
contentType?: string
headers?: Record<string, string>
timeout?: number
responseType?: 'json' | 'text' | 'blob' | 'arrayBuffer'
onSuccess: (data: unknown, headers: Headers) => M
onError: (error: ApiError) => M
}
IntervalEffect
Fires msg every ms milliseconds. Cancel with cancel(key).
export interface IntervalEffect<M = unknown> {
type: 'interval'
key: string
ms: number
msg: M
}
LogEffect
Write to the console as an effect (effects-as-data debug aid). The signal
runtime intentionally does NOT special-case a log effect in core — it is
just data handled here, like every other effect.
export interface LogEffect {
type: 'log'
message: string
level?: 'log' | 'info' | 'warn' | 'error' | 'debug'
data?: unknown
}
NotificationEffect
export interface NotificationEffect<M = unknown> {
type: 'notification'
title: string
body?: string
icon?: string
tag?: string
onClick?: () => M
onClose?: () => M
onError?: () => M
}
RaceEffect
export interface RaceEffect<M = unknown> {
type: 'race'
effects: BuiltinEffect<M>[]
}
RetryEffect
export interface RetryEffect<M = unknown> {
type: 'retry'
/** Only `http` effects are retriable — retry re-issues the request on failure. */
inner: HttpEffect<M>
maxAttempts: number
delayMs: number
/**
* Decide whether a given failure should be retried. `attempt` is 1-based (the
* attempt that just failed). Defaults to retrying only transient failures —
* `network`, `timeout`, `ratelimit`, and 5xx `server` errors — so a `401`,
* `403`, `404`, or `validation` error fails fast instead of hammering the
* server. On a `ratelimit` error carrying `retryAfter`, the wait honors it
* (`max(retryAfter*1000, backoff)`).
*/
retryOn?: (error: ApiError, attempt: number) => boolean
}
Runner
A single effect runner. types are the effect type discriminants this runner
claims; run executes the effect; completesWithoutDispatch is the static
signal used by sequence to advance past a fire-and-forget step immediately —
a step that never calls send would otherwise stall the chain forever.
run may RETURN a boolean to override completesWithoutDispatch on a per-call
basis (only cancel needs this: bare cancel completes without dispatching,
but cancel(token, inner) may dispatch via its inner effect). Returning
undefined/void falls back to the static completesWithoutDispatch.
managesCompletion (default false) marks a COMPOSITE runner that drives the
completion signal itself: instead of sequence's "first bubbled message means
done" leaf heuristic, dispatch hands such a runner the onComplete callback
(its 5th run argument) and the runner fires it explicitly. Only sequence
needs this — a nested sequence dispatches several messages (one per step), so
first-message completion would let an outer sequence fast-forward past a still
running inner one. (race/retry/debounce/cancel-with-inner each dispatch
exactly one terminal message, so the leaf first-message rule is already correct
for them.)
export interface Runner {
readonly types: readonly string[]
readonly completesWithoutDispatch: boolean
readonly managesCompletion?: boolean
run(
effect: { type: string },
send: InternalSend,
signal: AbortSignal,
deps: Deps,
onComplete?: () => void,
): boolean | void
}
SequenceEffect
export interface SequenceEffect<M = unknown> {
type: 'sequence'
effects: BuiltinEffect<M>[]
}
StorageGetEffect
Read a key from storage, dispatch the message returned by onLoad(value).
export interface StorageGetEffect<M = unknown> {
type: 'storage-get'
key: string
onLoad: (value: unknown) => M
scope: StorageScope
}
StorageRemoveEffect
Remove a key from storage. Fire-and-forget.
export interface StorageRemoveEffect {
type: 'storage-remove'
key: string
scope: StorageScope
}
StorageSetEffect
Write a JSON value to localStorage/sessionStorage. Fire-and-forget.
export interface StorageSetEffect {
type: 'storage-set'
key: string
value: unknown
scope: StorageScope
}
StorageWatchEffect
Listen for changes to a storage key. Fires the message returned by onChange(value) on cross-tab writes.
export interface StorageWatchEffect<M = unknown> {
type: 'storage-watch'
key: string
onChange: (value: unknown) => M
scope: StorageScope
}
TimeoutEffect
Fires msg once, after ms milliseconds. Auto-cancels if the component unmounts.
export interface TimeoutEffect<M = unknown> {
type: 'timeout'
ms: number
msg: M
}
UploadEffect
export interface UploadEffect<M = unknown> {
type: 'upload'
url: string
method?: string
body: FormData | Blob
headers?: Record<string, string>
/** Abort the upload after this many milliseconds (wires `xhr.timeout`). */
timeout?: number
onProgress: (loaded: number, total: number) => M
onSuccess: (data: unknown, status: number) => M
onError: (error: ApiError) => M
}
WebSocketEffect
export interface WebSocketEffect<M = unknown> {
type: 'websocket'
url: string
key: string
protocols?: string[]
onOpen?: () => M
onMessage: (data: unknown) => M
onClose?: (code: number, reason: string) => M
onError?: () => M
}
WebSocketSendEffect
export interface WebSocketSendEffect {
type: 'ws-send'
key: string
data: unknown
}
Constants
broadcastListenRunner
const broadcastListenRunner: Runner
broadcastRunner
const broadcastRunner: Runner
cancelRunner
const cancelRunner: Runner
clipboardReadRunner
const clipboardReadRunner: Runner
clipboardWriteRunner
const clipboardWriteRunner: Runner
debounceRunner
const debounceRunner: Runner
defaultRunners
Every built-in runner, in the original dispatch order.
const defaultRunners: readonly Runner[]
delay
Delay then dispatch a message — the effects-as-data form of setTimeout.
This is the replacement for the old core delay effect: delay(ms, msg) is
timeout(ms, msg) (fire msg once after ms; auto-cancels on unmount).
const delay
geolocationRunner
const geolocationRunner: Runner
httpRunner
const httpRunner: Runner
intervalRunner
const intervalRunner: Runner
logRunner
const logRunner: Runner
notificationRunner
const notificationRunner: Runner
raceRunner
const raceRunner: Runner
retryRunner
const retryRunner: Runner
sequenceRunner
const sequenceRunner: Runner
storageGetRunner
const storageGetRunner: Runner
storageRemoveRunner
const storageRemoveRunner: Runner
storageSetRunner
const storageSetRunner: Runner
storageWatchRunner
const storageWatchRunner: Runner
timeoutRunner
const timeoutRunner: Runner
uploadRunner
const uploadRunner: Runner
websocketRunner
const websocketRunner: Runner
wsSendRunner
const wsSendRunner: Runner