@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

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>

cancel()

export function cancel(token: string): CancelEffect
export function cancel(token: string, inner: BuiltinEffect): CancelReplaceEffect

debounce()

function debounce(key: string, ms: number, inner: BuiltinEffect): DebounceEffect

timeout()

function timeout<M>(ms: number, msg: M): TimeoutEffect

interval()

function interval<M>(key: string, ms: number, msg: M): IntervalEffect

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

storageSet()

function storageSet(key: string, value: unknown, scope: StorageScope = 'local'): StorageSetEffect

storageRemove()

function storageRemove(key: string, scope: StorageScope = 'local'): StorageRemoveEffect

storageGet()

function storageGet<M>(key: string, onLoad: (value: unknown) => M, scope: StorageScope = 'local'): StorageGetEffect<M>

storageWatch()

function storageWatch<M>(key: string, onChange: (value: unknown) => M, scope: StorageScope = 'local'): StorageWatchEffect<M>

broadcast()

function broadcast(channel: string, data: unknown): BroadcastEffect

broadcastListen()

function broadcastListen<M>(channel: string, onMessage: (data: unknown) => M): BroadcastListenEffect<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

retry()

function retry(inner: BuiltinEffect, opts: { maxAttempts: number; delayMs: number }): RetryEffect

upload()

function upload<M>(opts: {
  url: string
  method?: string
  body: FormData | Blob
  headers?: Record<string, string>
  onProgress: (loaded: number, total: number) => M
  onSuccess: (data: unknown, status: number) => M
  onError: (error: ApiError) => M
}): UploadEffect<M>

clipboardRead()

function clipboardRead<M>(opts: {
  onSuccess: (text: string) => M
  onError: (error: string) => M
}): ClipboardReadEffect<M>

clipboardWrite()

function clipboardWrite(text: string): ClipboardWriteEffect

notification()

function notification<M>(title: string, opts?: {
    body?: string
    icon?: string
    tag?: string
    onClick?: () => M
    onClose?: () => M
    onError?: () => M
  }): NotificationEffect<M>

geolocation()

function geolocation<M>(opts: {
  onSuccess: (position: { latitude: number; longitude: number; accuracy: number }) => M
  onError: (error: string) => M
  enableHighAccuracy?: boolean
}): GeolocationEffect<M>

sequence()

function sequence(effects: BuiltinEffect[]): SequenceEffect

race()

function race(effects: BuiltinEffect[]): RaceEffect

handleEffects()

function handleEffects<E extends { type: string }, M = never>(): EffectChain<E, M>

Types

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 }

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 }

StorageScope

export type StorageScope = 'local' | 'session'

EffectPlugin

Plugin handler — returns true if the effect was handled, false to pass through.

export type EffectPlugin<E, M> = (ctx: EffectCtx<E, M>) => boolean

Interfaces

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
}

CancelEffect

export interface CancelEffect {
  type: 'cancel'
  token: string
}

CancelReplaceEffect

export interface CancelReplaceEffect {
  type: 'cancel'
  token: string
  inner: BuiltinEffect
}

DebounceEffect

export interface DebounceEffect {
  type: 'debounce'
  key: string
  ms: number
  inner: BuiltinEffect
}

TimeoutEffect

Fires msg once, after ms milliseconds. Auto-cancels if the component unmounts.

export interface TimeoutEffect {
  type: 'timeout'
  ms: number
  msg: unknown
}

IntervalEffect

Fires msg every ms milliseconds. Cancel with cancel(key).

export interface IntervalEffect {
  type: 'interval'
  key: string
  ms: number
  msg: unknown
}

StorageSetEffect

Write a JSON value to localStorage/sessionStorage. Fire-and-forget.

export interface StorageSetEffect {
  type: 'storage-set'
  key: string
  value: unknown
  scope: StorageScope
}

StorageRemoveEffect

Remove a key from storage. Fire-and-forget.

export interface StorageRemoveEffect {
  type: 'storage-remove'
  key: string
  scope: StorageScope
}

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
}

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
}

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
}

SequenceEffect

export interface SequenceEffect {
  type: 'sequence'
  effects: BuiltinEffect[]
}

RaceEffect

export interface RaceEffect {
  type: 'race'
  effects: BuiltinEffect[]
}

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
}

UploadEffect

export interface UploadEffect<M = unknown> {
  type: 'upload'
  url: string
  method?: string
  body: FormData | Blob
  headers?: Record<string, string>
  onProgress: (loaded: number, total: number) => M
  onSuccess: (data: unknown, status: number) => M
  onError: (error: ApiError) => M
}

RetryEffect

export interface RetryEffect {
  type: 'retry'
  inner: BuiltinEffect
  maxAttempts: number
  delayMs: number
}

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
}

NotificationEffect

export interface NotificationEffect<M = unknown> {
  type: 'notification'
  title: string
  body?: string
  icon?: string
  tag?: string
  onClick?: () => M
  onClose?: () => M
  onError?: () => M
}

GeolocationEffect

export interface GeolocationEffect<M = unknown> {
  type: 'geolocation'
  onSuccess: (position: { latitude: number; longitude: number; accuracy: number }) => M
  onError: (error: string) => M
  enableHighAccuracy?: boolean
}

EffectCtx

export interface EffectCtx<E, M> {
  effect: E
  send: (msg: M) => void
  signal: AbortSignal
}