@llui/effects
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 and the returned cleanup
aborts it. It is (re)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 once aborted so a
re-mount of the same definition never inherits a dead signal. The cleanup is
memoized per generation so it always targets the controller live at its dispatch.
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
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 from the effect list, apply responses
to state via update(), return the final loaded state.
Requests are built with the SAME buildRequest/parseResponse core the live
http runner uses, so SSR pre-loading derives identical requests (real headers,
content-type, pass-through bodies, responseType) and passes the response's real
Headers to onSuccess. A rejected fetch (network failure / timeout) is mapped
through the effect's onError rather than silently dropped, so SSR and the client
converge on the same failure state.
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 }): 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
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: '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
export type BuiltinEffect =
| HttpEffect
| CancelEffect
| CancelReplaceEffect
| DebounceEffect
| TimeoutEffect
| IntervalEffect
| LogEffect
| StorageSetEffect
| StorageRemoveEffect
| StorageGetEffect
| StorageWatchEffect
| BroadcastEffect
| BroadcastListenEffect
| SequenceEffect
| RaceEffect
| WebSocketEffect
| WebSocketSendEffect
| RetryEffect
| UploadEffect
| ClipboardReadEffect
| ClipboardWriteEffect
| NotificationEffect
| GeolocationEffect
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 {
type: 'cancel'
token: string
inner: BuiltinEffect
}
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 {
type: 'debounce'
key: string
ms: number
inner: BuiltinEffect
}
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 {
type: 'interval'
key: string
ms: number
msg: unknown
}
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 {
type: 'race'
effects: BuiltinEffect[]
}
RetryEffect
export interface RetryEffect {
type: 'retry'
/** Only `http` effects are retriable — retry re-issues the request on failure. */
inner: HttpEffect
maxAttempts: number
delayMs: number
}
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.
export interface Runner {
readonly types: readonly string[]
readonly completesWithoutDispatch: boolean
run(effect: { type: string }, send: InternalSend, signal: AbortSignal, deps: Deps): boolean | void
}
SequenceEffect
export interface SequenceEffect {
type: 'sequence'
effects: BuiltinEffect[]
}
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 {
type: 'timeout'
ms: number
msg: unknown
}
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
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