Skip to content

API Reference

Bootstraps a Sygnal application.

function run(
component: RootComponent,
drivers?: Record<string, CycleDriver>,
options?: RunOptions
): SygnalApp
ParameterTypeDescription
componentRootComponentThe root component function (with optional .intent, .model, etc.)
driversRecord<string, CycleDriver>Additional drivers beyond the defaults (optional)
optionsRunOptionsConfiguration options (optional)
OptionTypeDefaultDescription
mountPointstring'#root'CSS selector for the DOM element to render into
fragmentsbooleanNo effect since 6.0: fragments (<>…</>) always work. The DOM driver splices a fragment’s children into its parent before each render, so they are diffed, moved (a keyed fragment, such as a Collection item returning <>…</>, moves as one) and hydrated like the parent’s own children. The children of a keyed fragment get keys derived from the fragment’s key and their own (or their tag and position): derived keys never collide with each other, but in a contrived case one could equal a key you wrote by hand on a sibling (avoid keys that look like "a""b")
useDefaultDriversbooleantrueInclude default drivers (DOM, STATE, EVENTS, LOG)
diagnosticsDiagnosticsMode | { mode?, ignore?, strict? }'off' ('warn' in the Vite dev server)Runtime diagnostics: 'off', 'collect', 'warn' or 'error', plus codes to ignore and strict mode (strict: true needs sygnal/diagnostics; without a mode it also turns diagnostics on). Takes precedence over the dev flag the Vite plugin sets
onError(error, info) => voidnoneApp-level error hook for reporting: called once per error, after the component’s own onError boundary, with { componentName?, action?, phase, driver? } (phase: 'view', 'reducer', 'effect', 'intent', 'context', 'declaration', 'driver', 'instantiate', 'dispose', 'widget', 'patch')
uidstring'u'The root of this app’s uid() ids. Give each app on a page its own, and pass the same value to renderToString when hydrating (SSR)
PropertyTypeDescription
sourcesobjectAll driver source objects
sinksobjectAll driver sink streams
dispose() => voidShuts down the application and cleans up listeners
hmr(newComponent?, state?) => voidHot-swap the root component, preserving state
import { run } from 'sygnal'
import RootComponent from './RootComponent.jsx'
// Basic usage
run(RootComponent)
// With custom mount point
run(RootComponent, {}, { mountPoint: '#app' })
// With custom drivers
import myDriver from './myDriver'
run(RootComponent, { MY_DRIVER: myDriver })
// Fail fast on any diagnostic (e.g. in CI)
run(RootComponent, {}, { diagnostics: 'error' })
// With timers (the timers static needs its driver)
import { makeTimerDriver } from 'sygnal'
run(RootComponent, { TIMER: makeTimerDriver() })
// With HMR (Vite)
const { hmr, dispose } = run(RootComponent)
if (import.meta.hot) {
import.meta.hot.accept('./RootComponent.jsx', hmr)
import.meta.hot.dispose(dispose)
}

Auto-configures JSX transform and HMR. Import from sygnal/vite.

import sygnal from 'sygnal/vite'
export default defineConfig({
plugins: [sygnal()],
})
OptionTypeDefaultDescription
disableJsxbooleanfalseSkip automatic JSX configuration
disableHmrbooleanfalseSkip automatic HMR injection
diagnosticsDiagnosticsMode | { mode, strict, ignore }'warn'Runtime diagnostics in the dev server ('off' injects nothing)
checkboolean | { strict, include, ignore, overlay }trueRun sygnal-check in the dev server, when installed
vitestSetupbooleantrueUnder Vitest, add sygnal/diagnostics to test.setupFiles
nativeGlobalThisbooleantrueAlias xstream’s globalthis polyfill to the native globalThis, in dev, build and Vitest
devtoolsboolean | { redux }trueInstall the DevTools bridge (sygnal/devtools) in the dev server; { redux: true } also connects the Redux DevTools extension

The HMR transform and the diagnostics setup run only in dev mode (vite / vite dev); production builds get none of it. Files that already contain import.meta.hot are left untouched.

See Bundler Configuration for every option and the dev-mode behavior.


Builds a component from an options object, for code that makes components from data (generators, wrappers). It returns an ordinary function component: a new function that calls view with the one view argument, with the other options assigned as its statics and name as its componentName. Writing the function and assigning its statics directly (Counter.model = …) is the usual form; both run the same way.

function defineComponent(options: DefineComponentOptions): Component
OptionTypeDescription
view({ state, context, ...props }) => vnodeThe view (required)
namestringThe component’s name (componentName: diagnostics, devtools, uid()); defaults to the view’s componentName or function name, or 'Component' for an anonymous inline view
any staticmodel, intent, initialState, isolatedState, calculated, context, onError, debug, connections, resources, route, head, uses, timers, persist, viewTransitions
import { defineComponent } from 'sygnal'
const Counter = defineComponent({
name: 'Counter',
view: ({ state }) => <button className="inc">{state.count}</button>,
initialState: { count: 0 },
intent: ({ DOM }) => ({ INCREMENT: DOM.click('.inc') }),
model: { INCREMENT: (state) => ({ ...state, count: state.count + 1 }) },
})

Statics already on the view (Card.initialState = …) are copied to the new component, and the options override them. The view function itself is not changed, so one view can back several definitions. defineComponent replaces the component({ ... }) factory that 6.0 removed; see Migrating to 6.0.


Renders a list of components from an array on state.

import { Collection } from 'sygnal'
<ul className="list">
<Collection of={ItemComponent} from="items" filter={item => !item.done} sort="name" />
</ul>

The lowercase <collection> tag works too, without an import.

A Collection has no element of its own: its items render directly into the parent element. Any prop not listed below goes to every item; className, style, class, data-* and the other attributes of the wrapper <div> that 6.0 removed are SYG612 (Migrating to 6.0).

PropTypeRequiredDescription
ofComponentYesThe component to instantiate for each item
fromstring | LensYesState property name or lens for the source array. Must be an array (SYG401); type-checkable with Collection<PROPS, STATE>
filter(item) => booleanNoFilter function — only items returning true are rendered
sortstring | object | array | functionNoSort items — string (field name, "asc", or "desc"), object ({ field: "asc" | "desc" | 1 | -1 }), array (multi-field), or comparator function
viewTransitionNamestringNoA CSS identifier prefix ("card"): each item with an id gets view-transition-name: card-<id> and view-transition-class: card on its root element (its own style values win; undefined ones do not), for View Transitions

Items are keyed by their id property if present, otherwise by their index in the state array (also under filter and sort). Ids should be unique: with duplicates only the first item renders, and SYG424 warns in development. An item without an id sees its index as state.id; writing its state back doesn’t store it.

An item removes itself from the collection by returning undefined from a state reducer:

Item.model = {
REMOVE: () => undefined
}

Removed items are disposed, recursively (nested Collections included). Reordering the array keeps each item’s instance. See Collections.


Conditionally renders one component from a set based on a name.

import { Switchable } from 'sygnal'
<Switchable of={{ tab1: Component1, tab2: Component2 }} current={state.activeTab} />
PropTypeRequiredDescription
ofRecord<string, Component>YesMaps names to components
currentstringYesName of the currently visible component
statestring | LensNoState slice for the switched components
  • Only the current page renders. A hidden page first renders when it is first shown.
  • Every page’s intent and actions keep running while it is hidden; its declarations (connections, resources) pause unless marked background: true. See Switchable.

Renders children into a different DOM container.

import { Portal } from 'sygnal'
<Portal target="#modal-root">
<div className="modal">Content</div>
</Portal>
PropTypeDescription
targetstringRequired. CSS selector for the destination container
childrenVNode[]Content to render in the target

Portal content is outside the component’s DOM event delegation scope. Use DOM.select('document').events('click').filter(...) to capture events on portal elements.


CSS-based enter/leave animations using a Vue-style name prop that generates six CSS classes.

import { Transition } from 'sygnal'
<Transition name="fade">
{state.visible && <div>Animated</div>}
</Transition>
PropTypeDefaultDescription
namestring'v'Base name for generated CSS classes ({name}-enter-from, {name}-enter-active, {name}-enter-to, {name}-leave-from, {name}-leave-active, {name}-leave-to)
durationnumber—Explicit timeout in ms. If omitted, listens for transitionend event

Around a <Collection> (which has no element of its own) it applies to each item: added items enter, removed items leave.

See Transitions guide for the full class lifecycle and CSS examples.


Shows fallback UI while children are not ready.

import { Suspense } from 'sygnal'
<Suspense fallback={<div>Loading...</div>}>
<AsyncComponent />
</Suspense>
PropTypeDescription
fallbackVNode | stringUI to show while children are pending
childrenVNode[]Children that may signal not-ready via the READY sink

Components control Suspense via the built-in READY sink:

// Component starts as not-ready, signals ready when data loads
MyComponent.model = {
DATA_LOADED: {
STATE: (state, data) => ({ ...state, data }),
READY: () => true,
},
}

Components without explicit READY model entries auto-emit true on instantiation.


Marks named content regions for child components to render in specific locations.

import { Slot } from 'sygnal'
<Card state="card">
<Slot name="header"><h2>Title</h2></Slot>
<Slot name="actions"><button>Save</button></Slot>
<p>Default content</p>
</Card>
PropTypeDescription
namestringSlot name. If omitted, content goes to the default slot
childrenVNode[]Content for this slot

The child component receives a slots object in its view parameters:

function Card({ state, slots }) {
return (
<div>
<header>{...(slots.header || [])}</header>
<main>{...(slots.default || [])}</main>
<footer>{...(slots.actions || [])}</footer>
</div>
)
}

Unnamed children (not wrapped in <Slot>) go to slots.default. The children parameter continues to work as before — it contains the same elements as slots.default.

See Slots guide for reactive updates and fallback patterns.


Code-split a component via dynamic import.

function lazy(loadFn: () => Promise<{ default: Component }>, options?: { when?: 'visible' | 'idle'; rootMargin?: string; placeholderHeight?: number | string }): Component & { load(): Promise<void> }
import { lazy } from 'sygnal'
const HeavyChart = lazy(() => import('./HeavyChart.jsx'))
const BelowTheFold = lazy(() => import('./BelowTheFold.jsx'), { when: 'visible' })

Renders a <div data-sygnal-lazy="loading"> placeholder until the import resolves. Static properties (intent, model, etc.) are copied from the loaded module’s default export. when defers the import until a placeholder enters the viewport ('visible', rootMargin) or the browser is idle ('idle'); the placeholder stays in its place (placeholderHeight: its min-height), a Suspense boundary shows its fallback only once the import has started, and renderToString renders the placeholder. load() starts the import now (preloading). Guide: Lazy Loading.


Creates a ref object for DOM element access.

function createRef<T = HTMLElement>(): Ref<T> // { current: T | null }
import { createRef } from 'sygnal'
const myRef = createRef()
function Box({ state }) {
return <div ref={myRef}>...</div>
}
Box.model = {
MEASURE: (state) => ({ ...state, width: myRef.current?.offsetWidth ?? 0 }),
}

The ref prop sets .current to the DOM element on mount and null on unmount.


Creates a stream-based ref that emits the DOM element.

function createRef$<T = HTMLElement>(): Ref$<T> // { current: T | null, stream: MemoryStream<T | null> }
import { createRef$ } from 'sygnal'
const el$ = createRef$()
function MyComponent({ state }) {
return <div ref={el$}>...</div>
}
MyComponent.intent = () => ({
MOUNTED: el$.stream,
})

Pass the ref object to the ref prop and use its .stream in intent.


Creates an imperative command channel for parent-to-child communication.

function createCommand(): Command
PropertyTypeDescription
send(type: string, data?: any) => voidSend a named command with optional data

When a Command object is passed as any prop to a child component, the child receives a commands$ source in intent:

MethodTypeDescription
select(type: string) => Stream<any>Returns a stream that emits the data from each matching command
import { createCommand } from 'sygnal'
const cmd = createCommand()
// Parent passes it as a prop...
function App({ state }) {
return <VideoPlayer commands={cmd} />
}
// ...and sends commands, typically from an EFFECT
App.model = {
PLAY: { EFFECT: () => cmd.send('play') },
SEEK: { EFFECT: () => cmd.send('seek', { time: 30 }) },
}
// Child reads via commands$ source in intent
VideoPlayer.intent = ({ commands$ }) => ({
PLAY: commands$.select('play'),
SEEK: commands$.select('seek'), // emits { time: 30 }
})

See Commands guide for usage patterns.


A built-in sink for side-effect-only model entries. Runs the reducer function but produces no state change and emits nothing to any driver.

Component.model = {
ACTION_NAME: {
EFFECT: (state, data, next, props) => { /* side effect; return nothing */ }
}
}
ParameterTypeDescription
stateSTATECurrent component state (with calculated fields)
dataanyData from the triggering action
next(action, data?, delay?) => voidDispatch a follow-up action
propsobjectCurrent props, children, slots, context, and signal: an AbortSignal aborted on unmount (EFFECT only)

An EFFECT may be async and call next() after an await: a rejection is reported as SYG214, and next() after unmount is ignored (Async work that isn’t HTTP).

// Send a command without changing state
App.model = {
PLAY: {
EFFECT: () => playerCmd.send('play'),
},
}
// Route to different actions based on state
App.model = {
ROUTE: {
EFFECT: (state, data, next) => {
if (state.mode === 'a') next('DO_A', data)
else next('DO_B', data)
},
},
}
// Combine with other sinks
App.model = {
SUBMIT: {
STATE: (state) => ({ ...state, submitting: true }),
EFFECT: () => formCmd.send('validate'),
},
}

Logs a warning (SYG219) if the handler returns a value — EFFECT handlers should not return anything.

See Effect Handlers guide for more patterns.


A built-in sink for element commands: calls a method of an element the component rendered, such as focus(), scrollIntoView() or a <dialog>’s showModal(). No driver to register. Guide: Element Commands.

// the value of an ELEMENT entry: a command, an array of them, or a reducer returning either (or ABORT)
type ElementSinkValue =
| ElementCommand
| ElementCommand[]
| ((state, data, next, props) => ElementCommand | ElementCommand[] | typeof ABORT)
// ElementCommand: { <method>: target, ...options }; target: a control or a selector
CommandCalls
{ focus: target, preventScroll?, focusVisible? }element.focus(options)
{ blur: target }, { select: target }, { click: target }element.blur(), element.select(), element.click()
{ scrollIntoView: target, block?, inline?, behavior? }element.scrollIntoView(options)
{ showModal: target }, { show: target }A <dialog>’s showModal(), show()
{ close: target, returnValue? }A <dialog>’s close(returnValue)
{ showPopover: target }, { hidePopover: target }, { togglePopover: target, force? }A popover’s methods
  • The first key of a command is the method; the other keys are its options, passed as one object (close gets returnValue). Any other method the element has also runs; add it to the ElementCommandRegistry interface for TypeScript.
  • A control made from a spec object with commands is asked first: { open: DueDate } calls spec.commands.open(element, options).
  • The target is looked up in the sending instance’s own view (children and Collection items are isolated); focusWithin(selector) reaches inside them. The command runs after the next patch at which the target exists; it gives up after about 1 s (SYG640). An unknown or DOM-mutating method is SYG641.
  • An array runs its commands in order; ABORT sends nothing. Nothing runs during server rendering.
  • In tests: t.commands('ELEMENT') lists the commands sent; with dom: 'real' they also run.

An ELEMENT focus target that reaches inside the sender’s children (PLAN-5). Guide.

function focusWithin(selector: string): WithinTarget
// ELEMENT: { focus: focusWithin('.title'), preventScroll?: boolean }

Focuses the first element under the sender’s root element that matches selector (a child component’s or a Collection item’s included), with the command’s options. It runs after the next patch, so an item the same action adds is there; no match does nothing (renderComponent() reports SYG640 when nothing in the view matches).


Creates an EVENTS sink function that puts { type, data } on the global event bus. Use it as the EVENTS value inside a model entry.

function event(type: string, payload?: any | ((state, data, next, props) => any)): EventSink
import { event } from 'sygnal'
Lane.model = {
DELETE_LANE: {
STATE: (state) => ({ ...state, deleting: true }),
EVENTS: event('DELETE_LANE', (state) => ({ laneId: state.id })),
},
RESET: { EVENTS: event('RESET') }, // no payload
DARK: { EVENTS: event('SET_MODE', 'dark') }, // static payload
}

Receivers subscribe with EVENTS.select('DELETE_LANE'), which emits the payload. With a SygnalEvents registry, type and the payload are type-checked.

emit(type, payload?) is an older helper that returns a whole model entry ({ EVENTS: fn }); it still works but isn’t canonical (see Alternative Forms).


Render a Sygnal component in isolation for testing. Creates a minimal Cycle.js runtime with mocked DOM, event bus, and state drivers.

function renderComponent(
component: ComponentFunction,
options?: RenderOptions
): RenderResult<State> // State inferred from the component (or renderComponent<State>(C))
OptionTypeDefaultDescription
initialStateanyComponent’s .initialStateOverride the component’s initial state
contextRecord<string, any>noneThe ancestors’ context, for testing a child alone; the component’s own .context entries win (Testing)
driversobject{}Additional drivers beyond the defaults
diagnosticsDiagnosticsMode'collect' (or the current mode)Diagnostics mode while rendered
strictbooleanunchangedStrict-mode runtime checks while rendered
mockConfigobject{}Mock DOM event streams, by selector
autoConnectbooleantrueFake socket connections open by themselves; false holds them until t.open()
socketSinkstring'WS'The driverless sink that receives the connections static
dom'mock' | 'real''mock''real' patches the tree into a real container element (Real DOM)
onError(error, info) => voidnoneThe app-level error hook, as run()’s onError
timerSinkstring'TIMER'The sink of the timer fake (the real makeTimerDriver() on the test’s clock), unless a driver is passed under that name
browserSinkstring'BROWSER'The sink of the browser fake (the real browser driver over fake sources, driven by t.browser), unless a driver is passed under that name
browserBrowserFakeOptions(see guide)The browser fake’s environment at the start: { media, storage, sessionStorage, visible, online, clipboard, position, deny } (Browser Sources)
storageRecord<string, entry | string>{}The fake storage of a root’s persist() ('local' and 'session' alike): key to { version, state } (with format: 'plain', the stored keys) or a raw string. Used as is: writes land in it, and calls given the same object share it

The Testing guide’s options table lists the rest (timing, HTTP, router and head fakes).

PropertyTypeDescription
simulateEvent(selector, type, init?) => voidDispatch a DOM event through the mock DOM (the real intent runs)
simulateAction(name, data?) => voidPush an action under its real name (every sink runs)
ready() => Promise<void>Resolves once subscribed and earlier calls are delivered
next(predicate?, timeout?) => Promise<any>Next matching state emitted after the call
waitForState(predicate, timeout?) => Promise<any>First matching state, recorded history included
settle(timeout?) => Promise<void>Resolves once nothing is pending
stateany (read-only)The latest state (states.at(-1))
statesany[]Every state emitted
html() => stringLatest render as HTML (throws before the first render: await t.ready() first)
emitted{ type, data }[]EVENTS emissions
sinkValues(sinkName) => any[]Values sent to a sink
requests(sinkName) => any[]Requests sent to a sink: sinkValues without the { abort } commands
respond(sinkName, value, target?) => Promise<void>Answer the newest pending request on a driverless source that matches target (an ok/error action name or category, { url } or another partial request, a predicate, or { request, category, status, body }): a request with reply actions gets value as its ok action, a plain one goes to select(). Throws at the call when nothing matching is pending (unless earlier simulated input is still queued); resolves after the reply is reduced and rendered (Testing)
fail(sinkName, error, target?) => Promise<void>Fail it the same way: its error action gets { error, request, status, body }, or errors() (a number is an HTTP status)
connections(sinkName) => FakeConnection[]Connections declared on a driverless socket sink (name, url, state, sender, the spec)
push(sinkName, data, target?) => Promise<void>A frame from the server on the matching open connections ({ event } for an SSE named event)
drop(sinkName, { code, reason }?, target?) => Promise<void>A close the app didn’t make: close fires, the fake reconnects per the spec
open(sinkName, target?) => Promise<void>Complete a pending open (with autoConnect: false)
sent(sinkName, to?) => any[]The { to, json | text | binary } values sent
diagnosticsDiagnostic[]Diagnostics collected while rendered
expectNoDiagnostics() => voidThrows if a warning or error was collected
actionsTestAction[]Live log of every action the tree ran: { type, data, component, instance, sinks, cause, at } (Action log)
explain(predicate) => ExplainedAction | undefinedThe first action whose resulting state matches, with that state and its STATE reducer
commands(sinkName = 'ELEMENT') => ElementCommand[]The element commands sent, one per command; another sink name gives its sinkValues
timers() => ActiveTimer[]The timers running now: each spec plus name, action, component
browserBrowserFakeThe browser fake: intersect, resize, media, storage, visibility, online, geolocation, clipboard, deny, active
storage(key) => { version, state } | undefinedThe fake storage’s entry for key (persist()); with format: 'plain', the stored keys (TS: t.storage<Entry>(key)). settle() makes the pending writes first
query, queryAll(selector | control) => Element | null, Element[]Elements of the latest render (snapshots on the mock DOM, real elements with dom: 'real')
widget(selector | control) => { props, instance, emit(name, detail?) }A widget’s host: the props the view passed it, the instance mount returned (dom: 'real'), and emit, which sends the event its emit() would
containerElement | nulldom: 'real': the mount element
inspect(options?) => InspectGraphApp graph of the rendered tree (needs sygnal/diagnostics); { actions: true } (or a number: the last n) adds recentActions
state$, dom$, events$, sinks, sourcesLive streams and driver objects
dispose() => voidTear down the tree and restore the diagnostics settings
import { renderComponent } from 'sygnal'
const t = renderComponent(Counter, { initialState: { count: 0 } })
t.simulateEvent('.inc', 'click')
await t.next(s => s.count === 1)
t.expectNoDiagnostics()
t.dispose()

See Testing guide for full usage patterns.


Render a Sygnal component to an HTML string for server-side rendering. Recursively renders sub-components, Collections, and special components.

function renderToString(
component: ComponentFunction,
options?: RenderToStringOptions
): string
OptionTypeDefaultDescription
stateanyComponent’s .initialStateState for the root component
propsRecord<string, any>{}Props to pass to the component
contextRecord<string, any>{}Parent context to merge with
hydrateStateboolean | string—Embed state in <script> tag for client hydration
onError(error, info) => void—App-level error hook, called with the phase 'view'
uidstring'u'The root of the uid() ids; the same value as run()’s uid on the client

See SSR for every option.

A custom element’s (a hyphenated tag’s) props are written as attributes, with camelCase names in kebab-case (withClear → with-clear); function and object props have no attribute form and are left out (Web components). A widget renders its host with its fallback inside.

import { renderToString } from 'sygnal'
// Basic usage
const html = renderToString(App, { state: { count: 0 } })
// The root element carries data-sygnal-ssr="" (removed by the first client render)
// With hydration state
const html = renderToString(App, {
state: { count: 5 },
hydrateState: true,
})
// Appends: <script>window.__SYGNAL_STATE__={"count":5}</script>

See Server-Side Rendering guide for full usage patterns.


A built-in model action that fires automatically when the component is about to unmount. This is the preferred way to handle component cleanup.

MyComponent.model = {
DISPOSE: {
EFFECT: (state) => {
clearInterval(state.intervalId)
},
},
}

Works with all sinks (EFFECT, EVENTS, PARENT, STATE). The reducer receives the current state, so you can access component data during cleanup. Disposal is recursive: removing a component disposes everything inside it, including nested Collections and Switchables.


A source stream available in every component’s intent. Emits true once when the component unmounts. Use this for advanced cases that need stream composition. For most cleanup tasks, the DISPOSE model action is simpler.

MyComponent.intent = ({ DOM, dispose$ }) => ({
CLEANUP: dispose$,
})
MyComponent.model = {
CLEANUP: {
WEBSOCKET: () => ({ type: 'close' }),
},
}

Not imported — automatically available as a source in intent.


Error boundary handler for a component.

Component.onError = (error: Error, info: { componentName: string }) => VNode | undefined
MyComponent.onError = (error, { componentName }) => (
<div>Error in {componentName}: {error.message}</div>
)

If not defined, errors render an empty <div data-sygnal-error> and log to console.error (SYG406). An onError that throws is reported as SYG407. To report errors from the whole app (to an error tracker), use run()’s onError option.


A stable id for the component instance, passed to the view and to reducers (on their props argument):

function uid(): string // this instance's id, e.g. 'u'
function uid(name: string): string // an id derived from it, e.g. 'u-email'

Ids come from the instance’s position in the tree (and its Collection item key), so they differ between instances, stay the same across renders, and match between renderToString and the client. Use them for id / for / aria-* pairs. uid is a reserved prop. The root is 'u', or run()’s and renderToString()’s uid option. Guide: Labels and ids.


Required when a sub-component declares .initialState (otherwise SYG405).

Widget.initialState = { count: 0 }
Widget.isolatedState = true
  • Bound to a slice (<Widget state="counter" /> or a lens): initialState seeds the slice only while it is undefined; an existing slice is kept (SYG425 warns in development when it lacks initialState’s keys).
  • No state prop: the state is local to the instance and never written to the parent.

<Widget state="counter" resetState /> replaces the slice with the child’s initialState each time the child is created. It is read at creation, like state, and is never a prop of the child. See State Management.


controls({ Name: 'input', Save: 'button' }) returns element tokens that the view renders (<Save>Save</Save>, a <button data-control="Save">) and the intent, element commands, behaviors and tests select (DOM.click(Save)). An alternative form to class selectors; see Controls.


defineWidget({ tag, mount, update, unmount, events, commands, fallback }) wraps a framework-agnostic widget (a date picker, a chart, an editor) as a JSX tag. The view renders it like an element and the intent selects it by class; it is also a control spec (controls({ Due: DatePicker })). See Widgets.

const DatePicker = defineWidget({
tag: 'input',
mount: (el, props, dispatch) => flatpickr(el, { defaultDate: props.value, onChange: ([date]) => dispatch('pick', date) }),
update: (picker, props) => picker.setDate(props.value, false),
unmount: (picker) => picker.destroy(),
events: ['pick'],
commands: { open: (picker) => picker.open() },
})
// <label>Due <DatePicker className="due" value={state.due} /></label>
// DUE: DOM.select('.due').events('pick').detail() OPEN: { ELEMENT: { open: '.due' } }
FieldDescription
tagThe host element (default 'div'); it has no children of its own
mount(el, props, dispatch, error)Called once the host is in the page; returns the instance. dispatch(name, detail) sends an event; error(e) reports a later failure the widget caught itself (a library’s own redraw), handled like a throwing update (SYG661)
update(instance, props, el)Called with the newest props when they change (shallow; style/attrs objects by their entries); without it, a change remounts
unmount(instance, el)Called when the host leaves the page
eventsThe names dispatch(name, detail) sends (bubbling CustomEvents on the host)
commandsElement commands, (instance, options, el) => …; they win over native methods of the same name (close and togglePopover get the options object too)
fallbackWhat server rendering puts inside the host: a vnode, a string, or (props, h) => vnode
hostPropsMore prop names to put on the host (besides id, className, style, title, name, placeholder, role, tabindex, hidden, lang, dir, attrs, aria-*, data-*)
ownPropsProp names that stay off the host (the widget applies them itself)
nameA name for diagnostics

A ref on the tag gets the host element; key and ref are not passed to the widget. A mount/update that throws, or a call to mount’s error(e), is reported to onError with the phase 'widget' and the owning component’s onError fallback renders in that widget’s place (SYG660–662):

const Sparkline = defineWidget({
tag: 'canvas',
mount: (el, props, dispatch, error) => {
const observer = new ResizeObserver(() => {
try { drawSparkline(el, props.points) } catch (e) { error(e) }
})
observer.observe(el)
return observer
},
unmount: (observer) => observer.disconnect(),
})

Types: Widget<P, I, EV, TAG>, WidgetDefinition, WidgetHostProps, WidgetDispatch.


The behaviors a component uses, each under a state key:

TaskList.uses = { pager: pager({ pageSize: 10, next: '.newer', prev: '.older' }) }

The behavior’s state is at state.pager (its calculated fields stored on it), and its actions are named after the key (pager.NEXT). A host model entry for a behavior action runs after the behavior’s; a host intent action of the same name replaces the behavior’s trigger. A key that is also in initialState, or a value that isn’t a behavior, is SYG127. Types: UsesState<typeof uses>, UsesActions<typeof uses>.


Defines a reusable behavior: state, intent and model without a view.

function defineBehavior(definition: {
initialState: Slice;
intent?: (sources, options, key) => { [action: string]: Stream<any> };
// handlers: (slice, data, next, props, options, key); HOST: (state, data, next, props, options, key) => state
model?: { [action: string]: Handler | { [sink: string]: Handler; HOST?: HostReducer } };
calculated?: { [field: string]: (slice) => any };
timers?: (slice, options, key) => { [name: string]: TimerSpec | false };
persist?: false; // UI state: a root's persist() neither saves nor restores the slice
undoStep?: string[]; // the actions that complete one undo() step (the others are gesture steps)
}): (options?) => Behavior

Returns a factory: call it with the options of one use (disclosure({ toggle: '.toggle' })). Options that name a key of initialState set that key’s starting value. The intent gets the host’s sources, the options and the use’s key; actions are named without the key. Model handlers get the slice, then the options and the key after props; a HOST entry is a reducer on the host’s whole state. timers declares timers for the host ('<key>.<name>'; a spec action naming one of the behavior’s actions is namespaced). Guide; persist and undoStep: Persisted state and undo steps.


A behavior: a page cursor over a list. Guide.

function pager(options?: { pageSize?: number; page?: number; total?: number | null; next?: Control | string; prev?: Control | string }): Behavior
Statepage (from 0), pageSize (20), total (null: unknown); calculated offset, pages, hasPrev, hasNext
ActionsNEXT, PREV (no change at the bounds), GOTO (a page number, kept in range), SET_TOTAL

A behavior: single or multiple selection over a list. Guide.

function selection(options?: { multi?: boolean; item?: Control | string; all?: Control | string; clear?: Control | string; attr?: string; from?: string; idField?: string }): Behavior
function isSelected(slice: { selected: string[] }, id: string | number): boolean
Stateselected (ids as strings, in selection order); calculated count
ActionsSELECT (an id, or a click on an item), SELECT_ALL, TOGGLE_ALL, CLEAR

isSelected(state.sel, id) compares ids as strings.


A behavior: reorders a list in the host’s state by pointer (mouse, pen, touch) and keyboard, with live-region announcements. Guide.

function sortable(options: {
from: string | string[]; item?: string; handle?: string; axis?: 'x' | 'y'; threshold?: number
attr?: string; idField?: string; label?: (entry: any) => string
messages?: Partial<Record<'lift' | 'move' | 'drop' | 'cancel', (label: string, position: number, count: number, extra?: any) => string>>
}): Behavior
Optionsfrom (required: the list’s state key, or keys for moves between lists, each container marked data-list), item ('[data-id]'), handle (default: the item), attr ('data-id'), idField ('id'), axis ('y'), threshold (4 px), label, messages
Statedragging, over, after, list, mode ('pointer' / 'keyboard' / null), message (for a live region), helpId (a uid() id for the instructions, set at the first focus, press or key inside the host); internal press, origin. Never saved by persist()
ActionsDROPPED ({ id, list, index, fromList, fromIndex }, once per completed move: add a host entry to save the order); internal INIT, HELP, END, PRESS, MOVE, UP, CANCEL, KEY. With undo() a drag is one undo step

Keys on a handle: Space / Enter lift and drop, the arrows and Home / End move (the cross axis changes lists), Escape restores, Tab drops. Focus follows the moved item (focusWithin). A pointer drag shows over / after while moving and reorders on release. Dev diagnostics SYG145–SYG147, SYG435; sygnal-check SYG724.


Undo history for one key of the state. Guide.

function undo(options: { key: string; limit?: number; track?: string[]; coalesceMs?: number; coalesce?: string[]; resetOn?: string[]; undo?: Control | string; redo?: Control | string }): Behavior
function undoable(model: Model, options: { key: string; limit?: number; track?: string[]; coalesceMs?: number; coalesce?: string[]; resetOn?: string[] }): Model

uses = { history: undo({ key: 'doc' }) } gives state.history = { past, future, canUndo, canRedo } and the actions history.UNDO / history.REDO. model = undoable({ ... }, { key: 'doc' }) wraps the model’s STATE reducers instead and adds plain UNDO / REDO actions. Options: limit (100 snapshots), track (only these actions are recorded), coalesceMs (changes by one action within this many ms are one step), coalesce (only these actions’ changes join a step, e.g. typing; every other action is always its own step; coalesceMs defaults to 500 with it), resetOn (actions that clear the history). A track or resetOn name with no model entry is SYG226.


A behavior: a form with validation over any Standard Schema. Guide.

function form<V>(schema: StandardSchemaLike, options: {
values: V; submit: string; check?: Record<string, { request: (value: any) => object; error?: (body: any) => string | false | null | undefined }>;
show?: 'blur' | 'input' | 'submit'; form?: string; http?: string
}): Behavior
Statevalues, initial, errors, touched, server, remote, pending, submitting, submitted, submitCount, queued, validating, validated; calculated fields (per name: { name, value, error, invalid, touched, dirty, pending }), valid, dirty, error
ActionsCHANGE ({ name, value }), BLUR (name), SUBMIT, ADD ({ field, value }), REMOVE ({ field, id }), ERRORS (server errors), DONE, RESET (values?)

Fields are matched by name inside the form element (form option, default 'form'); rows of an array by id (addresses.7.city). A valid submit dispatches the host’s submit action with the schema’s output; an invalid one focuses the first invalid field. Diagnostics: SYG230–SYG236.

The functions form() is built on, for a form written with plain actions. Guide.

function checkForm(schema: StandardSchemaLike, values: any): { errors: FieldErrors; value: any } | Promise<{ errors: FieldErrors; value: any }>
function formErrors(schema: StandardSchemaLike, values: any): FieldErrors | Promise<FieldErrors>
function setField<V>(values: V, name: string, value: any): V
function getField(values: any, name: string): any
function hasField(values: any, name: string): boolean // the path exists (an undefined value too)
function fieldName(values: any, path?: ReadonlyArray<any>): string
function fieldNames(values: any): string[]
function replyErrors(reply: any, values?: any): FieldErrors
function focusInvalid(names: string[] | FieldErrors, within?: string): ElementCommand | ABORT // within: the form's selector
type FieldErrors = Record<string, string> // by field name; '' = form-level

Saves the root component’s state in the browser’s storage and restores it at startup. Guide: Persistence.

function persist(options: {
key: string
pick?: (keyof State)[] // or omit
omit?: (keyof State)[]
version?: number // default 1
migrate?: (old: any, fromVersion: number) => Partial<State> | null | undefined
storage?: 'local' | 'session' | { getItem, setItem, removeItem, subscribe? } // default 'local'
sync?: boolean // apply other tabs' writes (RESTORE)
hydrate?: boolean // restore after the first render; detected when omitted
debounceMs?: number // default 100
format?: 'versioned' | 'plain' // default 'versioned'; 'plain': no version / migrate
}): Persist<State>
TodoApp.persist = persist({ key: 'todo-app', pick: ['todos', 'filter'], version: 2, migrate })
  • Stored as JSON { version, state }: the pick keys (or all but omit; never calculated fields). format: 'plain' stores those keys themselves ({"title":"…","body":"…"}), with no version / migrate (A plain format).
  • Restored synchronously before INITIALIZE and merged into initialState. When the app hydrates server-rendered HTML, in a RESTORE action after the first render instead, so that render matches the server’s markup. That is detected: renderToString() markup in run()’s mount point (its root element has data-sygnal-ssr), a server-rendered Astro island, a Vike hydration; hydrate: true / false override it (Server rendering). Another stored version goes through migrate (nothing returned, or no migrate: ignored).
  • Written after debounceMs without a state change, on pagehide and on dispose; not when unchanged.
  • Root component only (SYG224); a pick / omit key not in initialState is SYG223; a failed restore, migrate or write is SYG642 (warning) and the app continues.
  • Nothing is read or written during server rendering. In tests: renderComponent’s storage option and t.storage(key).

PERSIST: { clear: true } in a model entry of the persisting root removes the stored copy (a value, or a function of (state, data) returning it or ABORT). The state the same action produces isn’t saved. No driver.

Sent to the persisting root with the saved keys as its data: after the first render when the app hydrates server-rendered HTML (see persist()), and for another tab’s write with sync: true. The built-in entry merges them into the state ({ ...state, ...data }); a RESTORE model entry replaces it.


Creates a Cycle.js driver for HTML5 drag-and-drop that works across isolated components.

function makeDragDriver(): (sink$: Stream<DragDriverRegistration | DragDriverRegistration[]>) => DragDriverSource
import { run, makeDragDriver } from 'sygnal'
import RootComponent from './RootComponent.jsx'
run(RootComponent, { DND: makeDragDriver() })

Configuration objects emitted via the model sink to register drag categories:

type DragDriverRegistration = {
category: string // Required: name for this group of drag elements
draggable?: string // CSS selector for draggable elements
dropZone?: string // CSS selector for drop zones
accepts?: string // Only accept drops from this dragging category
dragImage?: string // CSS selector for custom drag preview (resolved via .closest())
}

Register categories from BOOTSTRAP in the model. Wrap in { configs: [...] } because model sinks cannot return bare arrays:

RootComponent.model = {
BOOTSTRAP: {
DND: () => ({
configs: [
{ category: 'task', draggable: '.task-card' },
{ category: 'lane', dropZone: '.lane-drop-zone', accepts: 'task' },
],
}),
},
}

The source object returned by the driver, available in intent as DND:

type DragDriverSource = {
select(category: string): DragDriverCategory
dragstart(category: string): Stream<DragStartPayload>
dragend(category: string): Stream<null>
drop(category: string): Stream<DropPayload>
dragover(category: string): Stream<any>
dispose(): void
}

The shorthand methods (dragstart, dragend, drop, dragover) are equivalent to select(category).events(eventName).

Returned by DND.select(category):

type DragDriverCategory = {
events(eventType: 'dragstart'): Stream<DragStartPayload>
events(eventType: 'dragend'): Stream<null>
events(eventType: 'drop'): Stream<DropPayload>
events(eventType: string): Stream<any>
}
type DragStartPayload = {
element: HTMLElement // The dragged element
dataset: Record<string, string> // The element's data-* attributes
}
type DropPayload = {
dropZone: HTMLElement // The drop zone element
insertBefore: HTMLElement | null // Sibling element at the cursor (for ordering)
}
RootComponent.intent = ({ DND }) => ({
DRAG_START: DND.dragstart('task'),
DROP: DND.drop('lane'),
DRAG_END: DND.dragend('task'),
})
RootComponent.model = {
DRAG_START: (state, { dataset }) => ({
...state,
dragging: { taskId: dataset.taskId },
}),
DROP: (state, { dropZone, insertBefore }) => {
const toLaneId = dropZone.dataset.laneId
// moveTask: your own helper that returns the new lanes array
return { ...state, lanes: moveTask(state.lanes, state.dragging.taskId, toLaneId, insertBefore) }
},
DRAG_END: (state) => ({ ...state, dragging: null }),
}

Extracts form field values from a form DOM source.

function processForm(
target: FormSource,
options?: { events?: string | string[]; preventDefault?: boolean }
): Stream<FormData>
ParameterTypeDescription
targetFormSourceA DOM source for a form element (from DOM.select('.my-form'))
options.eventsstring | string[]Events to listen for (default: ['input', 'submit'])
options.preventDefaultbooleanCall preventDefault() on events (default: true)

A stream that emits objects containing:

PropertyTypeDescription
[fieldName]anyEach form field’s value, keyed by its name attribute
eventEventThe raw DOM event
eventTypestringThe event type (e.g., 'input', 'submit')

If a submit button with a name attribute is focused, its name and value are also included.

import { processForm } from 'sygnal'
MyForm.intent = ({ DOM }) => ({
// All field changes and submits
FORM_DATA: processForm(DOM.select('.my-form')),
// Submit only
SUBMITTED: processForm(DOM.select('.my-form'), { events: 'submit' }),
// Custom events, no preventDefault
CHANGES: processForm(DOM.select('.my-form'), {
events: ['input', 'change'],
preventDefault: false
})
})

An HTTP driver over fetch. Guide: HTTP Requests with makeFetchDriver().

function makeFetchDriver(options?: {
baseUrl?: string; // prefix for every url
headers?: Record<string, string> | Headers; // for every request
init?: FetchInit; // fetch() options for every request, e.g. { credentials: 'include' }
latest?: boolean; // default for every request (false)
timeoutMs?: number; // default for every request (none)
parse?: 'auto' | 'json' | 'text' | 'response' | ((res: Response) => any); // default 'auto'
fetch?: typeof fetch; // default: globalThis.fetch, read at each request
}): Driver
Sink value (request)Effect
{ url, ok?, error?, key?, method?, query?, json?, body?, headers?, latest?, timeoutMs?, parse?, init? }fetch(baseUrl + url + ?query, init). Method defaults to POST with json/body, else GET. Other fetch options go under init; any other key is app data (not sent, returned on request)
'/api/x'GET of that URL (no reply actions)
{ abort: 'LOADED' }, { abort: true, key }Cancel this instance’s requests with reply actions in flight with that key (key, else the ok action, else error)
{ category?, abort: true }Cancel the component’s plain requests in flight in that category (all of its requests, without a category)
ABORT, null, undefinedNothing

Reply actions (canonical): a request naming ok / error is answered with that action, on exactly the sending instance.

ActionData
okThe parsed body of a 2xx response (parse: 'response': the Response)
error{ error, status?, body?, request } (FetchFailure): non-2xx (status, parsed body), network error, parse error, timeout (error.name === 'TimeoutError')

latest: true aborts this instance’s earlier requests with the same key still in flight; their replies never arrive. A removed instance’s requests with reply actions are aborted. A name with no model entry is SYG112; a then/catch key is SYG610 (not sent).

Without reply actions (no ok/error): replies go to the source.

SourceEmits
HTTP.select(category?){ category, value, status, request } for each 2xx response
HTTP.errors(category?){ error, category, request, status?, body? } for a non-2xx status, network error, parse error or timeout

Without reply actions, latest and abort act per category; each component instance sees only the replies to its own (and its children’s) requests, and the root sees all. Disposing the app aborts everything in flight. No requests are made during server rendering. Guide: HTTP.


WebSocket and server-sent events. Guide: Sockets.

function makeSocketDriver(options?: {
baseUrl?: string; // prefix for relative URLs
reconnect?: false | { delayMs?: number; maxDelayMs?: number; jitter?: boolean | number }; // default for every connection
queueLimit?: number; // sends kept per connection while (re)connecting (100)
WebSocket?: any; // default: globalThis.WebSocket, read at connect time
EventSource?: any; // default: globalThis.EventSource
}): Driver
Sink valueEffect
{ connections: { [name]: spec | falsy } }The sender’s whole set of connections, diffed by name: new names open, removed or falsy ones close, a changed URL / protocols / withCredentials / share reconnects. The connections static sends this for you
{ to: name, json? | text? | binary? }Send on the sender’s own WebSocket connection; queued while (re)connecting. Unknown, closed-for-good or SSE connection: SYG611

Spec: { socket: url, protocols? } or { sse: url, withCredentials?, events?: { eventName: 'ACTION' } }, plus the optional actions message (the JSON-parsed frame), open ({ reconnected }), close ({ code, reason, willReconnect }, only for closes the app didn’t make), error ({ error }), and reconnect (default { delayMs: 500, maxDelayMs: 10000, jitter: 0.2 }; false for none) and share (default true: one socket per URL, ref-counted). Events without an action go to WS.select(name?) as { name, type, data }. A disposed instance’s connections close; no connections during server rendering.

// (state: State & Calculated) => Connections: { [name]: spec | falsy }
Chat.connections = (state) => ({
room: state.room && { socket: `/ws/rooms/${state.room}`, message: 'RECEIVED' },
})

Sent to the makeSocketDriver() sink at startup and whenever the result changes (structurally equal results are not resent). Without a registered makeSocketDriver() nothing opens and nothing is reported. Use the static or a model-sent { connections } value, not both. In renderComponent, the static goes to the fake sink named by socketSink (default 'WS').


Runs the components’ timers statics. Guide: Timers.

function makeTimerDriver(): Driver
run(App, { TIMER: makeTimerDriver() })

The key is free (TIMER by convention): the core finds the driver by the static it takes. Each timer’s action goes to the instance that declared it, with every sink of its model entry. A removed instance’s timers stop; disposing the app stops all of them; nothing runs during server rendering. A component that declares timers without the driver is SYG643 in dev. renderComponent provides it (timerSink, t.timers()).

// (state: State & Calculated) => { [name]: spec | falsy }
Stopwatch.timers = (state) => ({
tick: state.running && { every: 100, action: 'TICK' },
})
SpecAction data
{ every: ms, action, background? }: repeats every ms (> 0), drift-free; late ticks coalesce (n jumps){ n, t }: tick number from 1, Date.now()
{ after: ms, action, background? }: once, after ms (≥ 0); not again while the same spec stays declared{ t }
{ frame: 'ACTION', background? }: every animation frame (requestAnimationFrame, else 16 ms){ t, dt }: Date.now(), ms since the previous frame (0 first)

The result is compared by name whenever the state changes: a new name starts, a falsy or missing one stops, a changed spec restarts, an equal one keeps running. A hidden Switchable page’s timers stop (and start from scratch when it is shown) unless background: true. An invalid spec is not started (SYG422). Types: TimerSpec, Timers, TimerTick, TimerAfter, TimerFrame.


Runs the components’ browser statics and the commands sent to its sink. Guide: Browser Sources.

function makeBrowserDriver(): Driver
function makeBrowserDriverWith(...sources: BrowserSource[]): Driver
run(App, { BROWSER: makeBrowserDriver() })
// only these sources (the others add no bytes)
run(App, { BROWSER: makeBrowserDriverWith(intersectionSource, mediaSource) })

The key is free (BROWSER by convention): the core finds the driver by the static it takes. Sources: intersectionSource, resizeSource, mediaSource, storageSource, visibilitySource, onlineSource, geolocationSource, clipboardSource. A component that declares browser without the driver is SYG643 in dev. renderComponent provides a fake (browserSink, browser, t.browser).

// (state: State & Calculated) => { [name]: spec | falsy }
Card.browser = (state) => ({
seen: !state.seen && { intersection: '.cover', action: 'SEEN' },
})
SpecAction data
{ intersection: selector | true, action, threshold?, rootMargin? }: the matched elements in the component (true: its root) entering or leaving the viewport{ visible, ratio, index, dataset }
{ resize: selector | true, action }: their content-box size{ width, height, index, dataset }
{ media: query, action }: a media query (its value first){ matches, media }
{ storage: key, action, area?: 'session', json?, error? }: a storage key, other tabs’ writes included (its value first){ key, value }
{ visibility: true, action }: the page’s visibility (its value first){ visible }
{ online: true, action }: the network (its value first){ online }
{ geolocation: true | PositionOptions, action, error? }: watchPosition (permission-gated){ latitude, longitude, accuracy, altitude, altitudeAccuracy, heading, speed, timestamp }; error: { code, message }

Any spec takes background: true. Compared by name whenever the state changes, as timers: a new name starts, a falsy or missing one stops, a changed spec restarts. A hidden Switchable page’s sources stop unless background: true. Commands from a model entry, on the driver’s sink: { copy: text, ok?, error? }, { paste: true, ok, error? } ({ text }), { setItem: key, value, area?, json? }, { removeItem: key, area? }. Diagnostics: SYG663 (invalid spec or command), SYG664 (source not in the driver), SYG665 (failed with no error action), SYG666 (intersection / resize with nothing to observe). Types: BrowserSpec, BrowserSources, BrowserIntersection, BrowserResize, BrowserPosition, BrowserCommand.


A DOM driver that applies the renders asked for by the components’ viewTransitions statics inside document.startViewTransition(). Guide: View Transitions.

function makeViewTransitionDOMDriver(
mountPoint?: string | Element | DocumentFragment, // default '#root'
options?: DOMDriverOptions // as makeDOMDriver
): Driver
run(App, { DOM: makeViewTransitionDOMDriver('#root') })

It replaces run()’s default DOM driver (use the same mount point). One action is one render of the page, held until the browser has its snapshot (a later render that arrives meanwhile replaces it), then applied. The first render, prefers-reduced-motion: reduce and browsers without the API apply at once. A new transition skips one that is still animating. Opt-in, so apps without View Transitions don’t ship it. renderComponent never animates.

// Array<action name>
Board.viewTransitions = ['MOVE']
App.viewTransitions = ['ROUTE'] // the router's reply action: route changes

When a listed action’s STATE reducer changes the state, the render it causes runs as a View Transition. Any component of the app can list its own actions. Without makeViewTransitionDOMDriver() the render applies at once, and dev reports SYG645. Elements are matched by view-transition-name (a style={{ viewTransitionName }}, or a Collection’s or VirtualCollection’s viewTransitionName prop, which names each item with an id <prefix>-<id>), which must be unique on the page.


Creates a Cycle.js driver from a Promise-returning function.

function driverFromAsync(
promiseReturningFunction: (...args: any[]) => Promise<any>,
options?: DriverFromAsyncOptions
): CycleDriver
ParameterTypeDescription
promiseReturningFunctionFunctionAn async function or function returning a Promise
optionsDriverFromAsyncOptionsConfiguration (optional)
OptionTypeDefaultDescription
selectorstring'category'Property name used to categorize and filter responses
argsstring | string[] | Function'value'How to extract function arguments from incoming commands
returnstring'value'Property name to wrap the return value in
pre(incoming) => incomingIdentityPre-process incoming sink values before argument extraction
post(result, incoming) => resultIdentityPost-process results before sending to source

A request with ok / error (from a component) is answered with that action on exactly the sending instance: ok gets the resolved value (after post), error gets { error, request }. There is no latest/abort (a call can’t be cancelled).

Source API (requests without reply actions)

Section titled “Source API (requests without reply actions)”

The driver source exposes:

source.select(selector?: string | Function): Stream
source.errors(selector?: string | Function): Stream<AsyncDriverError>
  • select() with no arguments returns all responses
  • select('name') filters responses where [selectorProperty] === 'name'
  • select(fn) filters responses using a custom predicate function
  • errors() takes the same selectors and emits failed requests: { error, request, [selectorProperty] }. Rejections never reach select(). While nothing listens to errors(), failures are only logged with console.error
import { driverFromAsync } from 'sygnal'
import { geocode } from './geo.js' // async (address) => ({ lat, lng })
run(RootComponent, { GEO: driverFromAsync(geocode) })
// Use in model: geocode(state.address); the reply is FOUND or NOT_FOUND
Place.model = {
FIND: { GEO: (state) => ({ value: state.address, ok: 'FOUND', error: 'NOT_FOUND' }) },
FOUND: (state, coords) => ({ ...state, coords }),
NOT_FOUND: (state, { error }) => ({ ...state, status: error.message }),
}

Creates a Cycle.js driver that registers a service worker and exposes lifecycle events as streams. (PWA Helpers guide)

function makeServiceWorkerDriver(
scriptUrl: string,
options?: ServiceWorkerOptions
): (sink$: Stream<ServiceWorkerCommand>) => ServiceWorkerSource
ParameterTypeDescription
scriptUrlstringPath to the service worker file (e.g., '/sw.js')
optionsServiceWorkerOptionsOptional configuration
OptionTypeDescription
scopestringRegistration scope for the service worker

Source API (requests without reply actions)

Section titled “Source API (requests without reply actions)”
source.select(type?: string): Stream
Event TypeEmitsDescription
'installed'trueWorker finished installing
'activated'trueWorker activated
'waiting'ServiceWorkerNew version waiting to activate
'controlling'trueWorker took control of the page
'error'ErrorRegistration or lifecycle error
'message'anyData from postMessage
CommandDescription
{ action: 'skipWaiting' }Tell waiting worker to activate immediately
{ action: 'postMessage', data: any }Send a message to the active worker
{ action: 'unregister' }Unregister the service worker
import { run, makeServiceWorkerDriver } from 'sygnal'
run(App, { SW: makeServiceWorkerDriver('/sw.js') })
App.intent = ({ SW, DOM }) => ({
UPDATE_READY: SW.select('waiting'),
APPLY_UPDATE: DOM.click('.update-btn'),
})
App.model = {
UPDATE_READY: (state) => ({ ...state, updateAvailable: true }),
APPLY_UPDATE: {
SW: () => ({ action: 'skipWaiting' }),
EFFECT: () => window.location.reload(),
},
}

A stream of booleans reflecting the browser’s online/offline state. (PWA Helpers guide)

const onlineStatus$: Stream<boolean>

Emits navigator.onLine immediately, then true/false on online/offline window events. SSR-safe — emits true once if window is undefined.

import { onlineStatus$ } from 'sygnal'
App.intent = () => ({
ONLINE_CHANGED: onlineStatus$,
})
App.model = {
ONLINE_CHANGED: (state, isOnline) => ({ ...state, isOffline: !isOnline }),
}

Captures the beforeinstallprompt browser event and exposes it reactively. (PWA Helpers guide)

function createInstallPrompt(): InstallPrompt
MethodReturnsDescription
select(type)Stream<any>Stream filtered by 'beforeinstallprompt' or 'appinstalled'
prompt()Promise | undefinedTriggers the deferred install prompt
import { createInstallPrompt } from 'sygnal'
const installPrompt = createInstallPrompt()
App.intent = ({ DOM }) => ({
CAN_INSTALL: installPrompt.select('beforeinstallprompt'),
INSTALL: DOM.click('.install-btn'),
})
App.model = {
CAN_INSTALL: (state) => ({ ...state, canInstall: true }),
INSTALL: {
EFFECT: () => installPrompt.prompt(),
STATE: (state) => ({ ...state, canInstall: false }),
},
}

LLM chat, decision models and the agent layer (6.1.0). Every export is pay-per-use: an app that doesn’t import sygnal/ai gets 0 bytes of it. The guides explain each part with live examples: AI Chat, Decisions, Agents, WebMCP and MCP Apps.

import { makeChatDriver, openResponses, chat, decide, choice } from 'sygnal/ai'
ExportWhat it isGuide
makeChatDriver({ transport, coalesce? })The chat driver: requests from a model sink, replies as actions (delta, ok, error, tool)AI Chat
openResponses(), chatCompletions(), uiMessageStream(), anthropicMessages(), agui(), fromAISDK(), chromePrompt()Transports: Open Responses (OpenAI, Ollama, vLLM, OpenRouter), Chat Completions, an AI SDK server route, Anthropic Messages, AG-UI servers, the AI SDK in process, Chrome’s on-device modelAI Chat
strictSchemasOpt-in strict mode for OpenAI and Anthropic schemas (strict: strictSchemas)AI Chat
encodeOpenResponses(events)Encodes Open Responses SSE events (test servers, demos)AI Chat
messageText(message), withToolResults(message, results)Message helpersAI Chat
chat(options)The in-app assistant behavior (uses): messages, streaming, the tool loop, approvalsAgents
decide(), choice(), noul(), score()Decision requests for makeFetchDriver, with answers typed from the questions; decide.openai() for OpenAI’s array formDecisions
answers(questions, picks)Typed decision replies for tests (t.respond)Decisions
commandBar(options)A command bar behavior on a decision modelAgents
agentTools(app, options?)The tools an app’s agent declarations offer: list, call, context, subscribe, stopAgents
toJsonSchema(), parseInput(), jsonSchema()The input-schema contract (Standard Schema → JSON Schema, validation, plain JSON Schema)Agents
outputJsonSchema(schema)The JSON Schema a transport sends for structured outputAI Chat
experimentalExposeWebMcp(app, options?)Offers the app’s tools to the browser’s agent through WebMCP (experimental)WebMCP
formTool(options)Makes a form a declarative WebMCP tool: form(schema, { tool: formTool({...}) }) (experimental)WebMCP
makeMcpAppDriver(options?)Runs a component as an MCP App view inside an MCP hostMCP Apps

The agent static’s queries (read-only tools that take an input: run(state, input) => result) are documented in Agents.

Also new in 6.1.0, from sygnal: the agent static on components, abort(reason) for reducers, the form.SET action, and the testing helpers t.stream, t.respond('LLM', …), t.tools(), t.callTool() and t.agentContext().

Runtime diagnostics helpers, exported from sygnal. They return data in every mode except 'off'.

ExportDescription
getDiagnostics()All diagnostics collected so far (most recent last, up to 500)
clearDiagnostics()Clear the collected diagnostics
onDiagnostic(callback)Call callback(diagnostic) for each new diagnostic; returns an unsubscribe function
getDevTools()The DevTools bridge (window.__SYGNAL_DEVTOOLS__) installed by the dev-only sygnal/devtools entry, or undefined (always in production builds): connected, getDiagnostics(), and inspect() once sygnal/diagnostics is loaded. See DevTools

A diagnostic is { code, severity, component?, message, fix?, data?, docsUrl, text, timestamp }, where text is the formatted [Sygnal CODE] … line. Every code is listed in the Error Reference.

The dev-only entry with the runtime checks. Importing it registers them; it also exports:

ExportDescription
inspect(options?)The app graph of the live components (InspectGraph)
configureStrict(on?)Strict-mode runtime checks on (true), off (false) or default (undefined: on when globalThis.__SYGNAL_STRICT__ === true)
isStrictEnabled()Whether the strict checks are on
checkEventBus()Report SYG105 for EVENTS types selected but never emitted; returns { selected, emitted, selectedNeverEmitted, emittedNeverSelected }
listCodes() / getCodeInfo(code){ code, severity, title, docsSlug } for the registered codes
configureChecks({ settleMs, idleMs, minRenders })Timing of the DOM checks (SYG103/104)
resetChecks()Forget what the checks have seen and reported
installChecks()Re-register the checks (done on import); returns an uninstall function
checks, RXJS_HINTSThe registered checks, and the RxJS → xstream table behind SYG301

The dev-only entry for the DevTools extension. Importing it installs the bridge (window.__SYGNAL_DEVTOOLS__) and, in a browser, starts the action log. The Vite plugin imports it in the dev server; production builds never contain it.

ExportDescription
getDevTools(), installDevTools()The bridge (installed on import); it also has configureCopyAsTest(options) and getSession(target?)
getActions(filter?), onAction(fn), clearActions()The action log: every instance’s actions with their cause, sinks and state change
recordActions(), isRecording()Start recording (automatic in a browser) and check it
copyAsTest(target?, options?), copyAsTestResult(target?, options?)”Copy as test”: a renderComponent test that replays a session
getSession(target?)One instance’s recorded session, as plain data
connectReduxDevtools(target?, { name?, filter? })Send the actions and the root’s state to the Redux DevTools extension; returns a disconnect function

See From code for the arguments.


defineElement(tag, Component, options?) from sygnal/element publishes a component as a custom element (props from attributes and properties, sinks as DOM events, optional shadow root). See Web components: publishing.


The xstream Observable library, re-exported for convenience.

import { xs } from 'sygnal'
MethodDescription
xs.of(...values)Create a stream from values
xs.never()A stream that never emits
xs.empty()A stream that immediately completes
xs.periodic(ms)Emits incrementing numbers at an interval
xs.merge(...streams)Combine multiple streams — emits whenever any stream emits
xs.combine(...streams)Combine latest values from multiple streams
xs.fromPromise(promise)Create a stream from a Promise
MethodDescription
.map(fn)Transform emitted values
.mapTo(value)Replace all emissions with a constant value
.filter(fn)Only pass values where the predicate returns true
.startWith(value)Emit an initial value before the stream’s first emission
.remember()Cache the last emitted value for late subscribers
.flatten()Unwrap a stream of streams
.compose(operator)Apply a stream operator
.fold(fn, seed)Accumulate values (like reduce for streams)
.drop(n)Skip the first N emissions
.take(n)Only emit the first N values
.last()Emit only the final value
.endWhen(other$)Complete when another stream emits

See the full xstream documentation for more.


Sygnal re-exports commonly used xstream extra operators:

import { debounce, throttle, delay, dropRepeats, sampleCombine } from 'sygnal'

Wait for a pause in emissions before passing the latest value through.

const search$ = input$.compose(debounce(300))

Emit at most once per time period.

const scroll$ = scrollEvents$.compose(throttle(200))

Delay all emissions by a fixed duration.

const delayed$ = click$.compose(delay(500))

Drop consecutive duplicate values. Optionally provide a custom equality function.

const unique$ = values$.compose(dropRepeats())
const customUnique$ = objects$.compose(dropRepeats((a, b) => a.id === b.id))

When the source emits, combine with the latest value from other streams.

const withState$ = click$.compose(sampleCombine(state$))
// Emits [clickEvent, latestState] each time click$ fires

A stream, in intent, of a value selected from the component’s state, emitted only when that value changes (compared structurally). Guide: Reacting to state changes.

interface StateSource<State> {
watch<T>(selector: (state: State) => T, options?: { immediate?: boolean }): Stream<T>
}
Notes.intent = ({ STATE }) => ({
SAVE: STATE.watch(state => state.text).compose(debounce(1000)),
})

By default only later changes are emitted; { immediate: true } emits the current value first. In a Collection item, state is the item’s state. The stream ends when the component is disposed. The raw state stream is STATE.stream.


The DOM source wraps @cycle/dom’s MainDOMSource with a Proxy that adds shorthand event methods. Any property access that doesn’t already exist on the source becomes an event listener factory:

type SygnalDOMSource = MainDOMSource & {
// standard events are typed like DOM.select(selector).events(name):
// DOM.keydown(sel): Stream<KeyboardEvent>, DOM.click(sel): Stream<MouseEvent>, …
[eventName: string]: (selector: string) => Stream<Event>
}
// DOM.eventName(selector) is equivalent to DOM.select(selector).events(eventName)
DOM.click('.btn') // DOM.select('.btn').events('click')
DOM.dblclick('.title') // DOM.select('.title').events('dblclick')
DOM.keydown('.input') // DOM.select('.input').events('keydown')
DOM.blur('.field') // DOM.select('.field').events('blur')
DOM.submit('.form') // DOM.select('.form').events('submit')
DOM.mouseenter('.card') // DOM.select('.card').events('mouseenter')

Any valid DOM event name works. The original .select().events() API is unchanged.

All DOM event streams (from .events() or shorthands) have chainable convenience methods:

DOM.input('.field').value() // e.target.value
DOM.change('.checkbox').checked() // e.target.checked
DOM.click('.item').data('id') // e.target.dataset.id (walks up via closest())
DOM.click('.task').data('taskId') // camelCase name → data-task-id attribute
DOM.keydown('.input').key() // e.key
DOM.click('.btn').target() // e.target
DOM.select('.due').events('pick').detail() // e.detail (a CustomEvent's payload)

Each method optionally accepts a transform function:

DOM.input('.count').value(Number) // Parse as number
DOM.click('.item').data('id', Number) // Parse data attribute as number
MethodExtractsNotes
.value(fn?)e.target.valueFor input/textarea/select
.checked(fn?)e.target.checkedFor checkboxes
.data(name, fn?)dataset[name] of e.target or its nearest ancestor with the attributename camelCase or kebab-case: 'taskId' and 'task-id' both read data-task-id (via closest('[data-task-id]'))
.key(fn?)e.keyFor keyboard events
.target(fn?)e.targetThe DOM element
.detail(fn?)e.detailA CustomEvent’s payload: a widget’s dispatch(name, detail), a web component’s event, a defineElement element’s sink

Returns enriched streams — chainable with .compose(), .filter(), etc.

The DND driver source provides equivalent shorthands as explicit methods:

DND.dragstart('task') // DND.select('task').events('dragstart')
DND.dragend('task') // DND.select('task').events('dragend')
DND.drop('lane') // DND.select('lane').events('drop')
DND.dragover('lane') // DND.select('lane').events('dragover')

See makeDragDriver() for full DND source documentation.


Declarative JSX props for managing element focus. These are handled by the pragma layer and never reach the DOM.

<input autoFocus={true} />

When the element is inserted into the DOM, .focus() is called on it. Works on any focusable element (input, textarea, select, button, elements with tabindex, etc.).

<input autoFocus={true} autoSelect={true} value={state.title} />

When used alongside autoFocus, .select() is called after .focus(), selecting all text in the element. Only meaningful on elements that support text selection (input, textarea).

  • Props are removed from the element before rendering — they do not become HTML attributes
  • A snabbdom insert hook is injected automatically
  • If you set your own hook={{ insert: fn }}, both hooks run (yours first, then focus)
  • autoSelect without autoFocus still triggers focus (both imply focusing the element)
function EditableTitle({ state }) {
return (
<div>
{state.isEditing
? <input
autoFocus={true}
autoSelect={true}
value={state.title}
className="title-input"
/>
: <h2 className="title">{state.title}</h2>
}
</div>
)
}

Sygnal re-exports all DOM helpers from @cycle/dom:

import { h, div, span, input, button, form, a, ul, li, p, ... } from 'sygnal'

Create virtual DOM nodes without JSX:

import { h } from 'sygnal'
// h(selector, data?, children?)
h('div.my-class', { style: { color: 'red' } }, [
h('h1', 'Hello'),
h('button.btn', 'Click me')
])
import { div, h1, button, input } from 'sygnal'
div('.container', [
h1('Hello'),
button('.btn', 'Click me'),
input('.text-input', { attrs: { type: 'text', placeholder: 'Enter name' } })
])