# Sygnal
> Reactive component framework (Cycle.js Model-View-Intent, JSX views, xstream streams). `npm i sygnal`; Vite: `import sygnal from 'sygnal/vite'`, `plugins: [sygnal()]`; entry: `run(App)`. This file shows only the canonical forms. Strict mode (`sygnal-check --strict`, runtime `strict: true`) flags other forms as SYG5xx; forms 6.0 removed (positional views, `'A | SINK'` keys, `CHILD.select('Name')`, `.components`, `.peers`, `component()`) are SYG612: https://sygnal.js.org/guide/migrating-to-6/
## 1. Mental model
- A component is a pure view function plus static properties: `.intent` (WHEN: sources → named action streams), `.model` (WHAT: action → one reducer per sink), `.initialState`.
- There is one state tree. A child sees its parent's whole state, or a slice with `state="key"`. A Collection item sees one array element.
- Every side effect is a sink of a model entry (STATE, EVENTS, PARENT, EFFECT, custom drivers). JSX never has event handlers (`onClick`).
- Streams are xstream, not RxJS. An intent's DOM selectors only see the component's own JSX, never a child component's.
## 2. Component anatomy
```jsx
import { ABORT } from 'sygnal'
function Counter({ state, context, label }) { // 1st arg: parent props + state, context, children, slots
return (
export default Counter
```
- Reducer `(state, data, next, props)`. `data` is the action stream's value. `next('ACTION', data?, delayMs = 10)` dispatches another action of this component `delayMs` after the call, whenever it is called: also later, from a timer or callback in an EFFECT (HTTP goes through `makeFetchDriver`, not `fetch` + `next()`). `props` holds the parent's props plus `state`, `context`, `children`, `slots`.
- Model entry: a function is the STATE reducer. An object `{ STATE, EVENTS, PARENT, EFFECT, LOG, : fn }` maps each sink to a `(state, data, next, props)` function whose return value goes to that sink. `: true` forwards `data` as-is.
- **Every sink of one entry sees the state from before this action**: EVENTS, PARENT, EFFECT and drivers never see what STATE returns. Compute the new value from `(state, data)` inside the sink: `INC: { STATE: s => ({ ...s, n: s.n + 1 }), PARENT: s => ({ n: s.n + 1 }) }`.
- Built-in actions (model only): `BOOTSTRAP` (once, just after mount), `INITIALIZE` (automatic: sets initialState), `DISPOSE` (unmount). `HYDRATE` is not built in (6.0): SSR data comes from Vike `+data` / `hydrateState`. Declaration statics (`connections`, `resources`, `route`, `head`) are computed from state, with or without a model; a root that declares one needs `initialState` (SYG132). Also reserved as statics: `timers`, `uses`, `persist`, `viewTransitions` (§3).
## 3. Canonical examples
### State update, ABORT, set / toggle, controlled input
```jsx
import { ABORT, set, toggle } from 'sygnal'
function AddTodo({ state }) {
return (
{state.showHelp &&
Type, then Add
}
)
}
AddTodo.initialState = { draft: '', todos: [], nextId: 1, showHelp: false }
AddTodo.intent = ({ DOM }) => ({
DRAFT: DOM.input('.draft').value(), // a bound value={...} needs an input listener (SYG111)
ADD: DOM.click('.add'),
HELP: DOM.click('.help'),
})
AddTodo.model = {
DRAFT: set((state, draft) => ({ draft })), // merges: ({ ...state, draft }); never set('draft') (SYG221)
ADD: (state) => {
const text = state.draft.trim()
if (!text) return ABORT // "no change" (returning the same `state` works too)
return { ...state, draft: '', nextId: state.nextId + 1, todos: [...state.todos, { id: state.nextId, text }] }
},
HELP: toggle('showHelp'),
}
```
### Multi-sink entry + EVENTS between non-adjacent components
```jsx
import { event } from 'sygnal'
function SaveButton({ state }) {
return
}
SaveButton.intent = ({ DOM }) => ({ SAVE: DOM.click('.save') })
SaveButton.model = {
SAVE: {
STATE: (state) => ({ ...state, saving: true }),
EVENTS: event('DOC_SAVED', (state) => ({ id: state.id })), // or event('RESET'), event('MODE', 'dark')
},
}
function Toast({ state }) { // anywhere in the tree: EVENTS is a global bus
return
)
}
TaskItem.intent = ({ DOM }) => ({ PICK: DOM.click('.pick'), REMOVE: DOM.click('.remove') })
TaskItem.model = {
PICK: { PARENT: (state) => ({ taskId: state.id }) }, // the parent receives exactly this value
REMOVE: () => undefined, // a Collection item removes itself
}
function TaskList({ state }) {
return (
{state.picked}
)
}
TaskList.initialState = { picked: null, tasks: [{ id: 1, title: 'Write' }, { id: 2, title: 'Test' }] }
TaskList.intent = ({ CHILD }) => ({ PICKED: CHILD.select(TaskItem) }) // the function, not a string
TaskList.model = { PICKED: (state, { taskId }) => ({ ...state, picked: taskId }) }
```
- `from` names an array field. Items are keyed by `.id` (by index if they have none). `filter={t => !t.done}`; `sort="title"`, `{ title: 'desc' }`, an array of those, or a compare function. Sorting and filtering only change what renders: the state array keeps its order, and an item's edit (`{ ...state, done: true }`) is written back to its element by key. Items render directly into the parent element, with no wrapper (`` is SYG612: put the class on your own `
`). Switchable: `` (optional `state="slice"`); hidden pages stay alive, and `instance={key}` re-creates the current page with fresh state when the key changes (a router: `instance={state.route.path}`).
### Behaviors (`uses`): shared state + actions
- `TaskList.uses = { pager: pager({ pageSize: 10, next: '.newer', prev: '.older' }) }` adds `state.pager` (`{ page, pageSize, total }` + calculated `offset`, `pages`, `hasPrev`, `hasNext`) and the actions `pager.NEXT`, `pager.PREV`, `pager.GOTO`, `pager.SET_TOTAL`; a click on what `next`/`prev` name triggers them. Options set the slice's start values; the key must not also be in `initialState` (SYG127). Also from `'sygnal'`: `selection({ multi: true, item: '.pick', from: 'mails' })` (`data-id` on each item; `isSelected(state.sel, id)`) and `undo({ key: 'doc', undo: '.undo', redo: '.redo' })` (`state.history.canUndo`/`canRedo`; `coalesce: ['TYPE'], coalesceMs: 500` groups only typing, each other action is its own step; `key: ['todo', 'done']` records several keys as one step; `node_modules/sygnal/dist/guide/undo.md`).
- Your own: `const disclosure = defineBehavior({ initialState: { open: false }, intent: ({ DOM }, { toggle }) => ({ TOGGLE: DOM.click(toggle) }), model: { TOGGLE: (slice) => ({ ...slice, open: !slice.open }) } })`, used as `uses = { more: disclosure({ toggle: '.more' }) }`. Its reducers and `calculated` get and return the slice (`state.more`), its action names have no key, and any sink works. Options: `persist: false` (UI state `persist()` never saves), `undoStep: ['DONE']` (its other actions form one gesture: one undo entry when `DONE` completes it).
- The host names behavior actions with the key: an intent action `'more.TOGGLE': DOM.keydown(…)` replaces the behavior's trigger; a model entry `'more.TOGGLE': (state) => …` runs after the behavior's, on the full state; tests use `t.simulateAction('more.TOGGLE')`. Guide: `node_modules/sygnal/dist/guide/behaviors.md`
### Forms with validation (the `form` behavior)
- `Signup.uses = { form: form(schema, { values: { email: '', addresses: [{ id: 1, city: '' }] }, submit: 'SIGN_UP' }) }` (`form` from `'sygnal'`), any Standard Schema (zod, valibot, or `{ '~standard': { version: 1, vendor, validate } }`). Fields inside the `