A reactive component framework with pure functions, zero side effects, and automatic state management.
- Pure components — Views are plain functions. All side effects are handled by drivers, outside your code.
- Automatic state management — Monolithic state tree with no store setup, no providers, no hooks. Trivial undo/redo and time-travel debugging.
- Model-View-Intent — Cleanly separate what happens (Model), when it happens (Intent), and how it looks (View).
- Tiny footprint — Three runtime dependencies: snabbdom, xstream, and extend.
Scaffold a new project:
npm create sygnal-app my-app
cd my-app
npm run devChoose from Vite (SPA), Vite + PWA, Vike (SSR), or Astro templates in JavaScript or TypeScript.
Or add to an existing project:
npm install sygnalA component is a function (the view) with static properties that define when things happen (.intent) and what happens (.model):
function Counter({ state }) {
return (
<div>
<h1>Count: {state.count}</h1>
<button className="increment">+</button>
<button className="decrement">-</button>
</div>
)
}
Counter.initialState = { count: 0 }
Counter.intent = ({ DOM }) => ({
INCREMENT: DOM.select('.increment').events('click'),
DECREMENT: DOM.select('.decrement').events('click'),
})
Counter.model = {
INCREMENT: (state) => ({ ...state, count: state.count + 1 }),
DECREMENT: (state) => ({ ...state, count: state.count - 1 }),
}Start it:
import { run } from 'sygnal'
import Counter from './Counter.jsx'
run(Counter)No store setup, no providers, no hooks — just a function and some properties.
Render dynamic lists with built-in filtering and sorting:
<Collection of={TodoItem} from="items" filter={item => !item.done} sort="name" />Swap between components based on state:
<Switchable of={{ home: HomePage, settings: SettingsPage }} current={state.activeTab} />Top-down data propagation without prop drilling:
App.context = {
theme: (state) => state.settings.theme,
currentUser: (state) => state.auth.user,
}
function Child({ state, context }) {
return <div className={context.theme}>{context.currentUser.name}</div>
}Structured message passing between components:
// Child emits
TaskCard.model = {
DELETE: { PARENT: (state) => ({ type: 'DELETE', taskId: state.id }) }
}
// Parent receives (use component reference — minification-safe)
Lane.intent = ({ CHILD }) => ({
TASK_DELETED: CHILD.select(TaskCard).filter(e => e.type === 'DELETE'),
})Global broadcast for cross-component communication:
// Any component can emit
Publisher.model = {
NOTIFY: { EVENTS: (state) => ({ type: 'notification', data: state.message }) }
}
// Any component can subscribe
Subscriber.intent = ({ EVENTS }) => ({
HANDLE: EVENTS.select('notification'),
})Derived state with optional dependency tracking:
Invoice.calculated = {
subtotal: [['items'], (state) => sum(state.items.map(i => i.price))],
tax: [['subtotal'], (state) => state.subtotal * 0.08],
total: [['subtotal', 'tax'], (state) => state.subtotal + state.tax],
}Extract form values without the boilerplate:
MyForm.intent = ({ DOM }) => ({
SUBMITTED: processForm(DOM.select('.my-form'), { events: 'submit' }),
})HTML5 drag-and-drop with a dedicated driver:
import { makeDragDriver } from 'sygnal'
run(RootComponent, { DND: makeDragDriver() })Wrap any async operation as a driver:
import { driverFromAsync } from 'sygnal'
const apiDriver = driverFromAsync(async (url) => {
const res = await fetch(url)
return res.json()
}, { selector: 'endpoint', args: 'url', return: 'data' })
run(RootComponent, { API: apiDriver })Catch and recover from rendering errors:
BrokenComponent.onError = (error, { componentName }) => (
<div>Something went wrong in {componentName}</div>
)Render children into a different DOM container:
<Portal target="#modal-root">
<div className="modal">Modal content</div>
</Portal>Pass named content regions to child components:
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>
// In Card's view:
function Card({ state, slots }) {
return (
<div>
<header>{...(slots.header || [])}</header>
<main>{...(slots.default || [])}</main>
<footer>{...(slots.actions || [])}</footer>
</div>
)
}CSS-based enter/leave animations:
<Transition name="fade" duration={300}>
{state.visible && <div>Animated content</div>}
</Transition>Code-split components with loading boundaries:
const HeavyChart = lazy(() => import('./HeavyChart.jsx'))
<Suspense fallback={<div>Loading...</div>}>
<HeavyChart />
</Suspense>Access DOM elements declaratively:
const inputRef = createRef()
<input ref={inputRef} />
// inputRef.current.focus()Send imperative commands from parent to child:
import { createCommand } from 'sygnal'
const playerCmd = createCommand()
<VideoPlayer commands={playerCmd} />
// Parent sends commands with optional data
playerCmd.send('seek', { time: 30 })
// Child receives via commands$ source
VideoPlayer.intent = ({ commands$ }) => ({
SEEK: commands$.select('seek'), // emits { time: 30 }
})Run side effects without state changes — no more ABORT workarounds:
App.model = {
SEND_COMMAND: {
EFFECT: () => playerCmd.send('play'),
},
ROUTE: {
EFFECT: (state, data, next) => {
if (state.mode === 'a') next('DO_A', data)
else next('DO_B', data)
},
},
}Compact syntax for single-driver model entries:
App.model = {
'SEND_CMD | EFFECT': () => playerCmd.send('play'),
'NOTIFY | EVENTS': (state) => ({ type: 'alert', data: state.message }),
'DELETE | PARENT': (state) => ({ type: 'DELETE', id: state.id }),
}Cleanup on unmount with the built-in DISPOSE action:
MyComponent.model = {
DISPOSE: {
EFFECT: (state) => clearInterval(state.timerId),
},
}For advanced cases needing stream composition, the dispose$ source is also available in intent.
Built-in service worker driver, online/offline detection, and install prompt handling:
import { run, makeServiceWorkerDriver, onlineStatus$, createInstallPrompt } from 'sygnal'
const installPrompt = createInstallPrompt()
run(App, { SW: makeServiceWorkerDriver('/sw.js') })
App.intent = ({ DOM, SW }) => ({
ONLINE_CHANGED: onlineStatus$,
UPDATE_READY: SW.select('waiting'),
APPLY_UPDATE: DOM.click('.update-btn'),
INSTALL: DOM.click('.install-btn'),
})Test components in isolation with renderComponent:
import { renderComponent } from 'sygnal'
const t = renderComponent(Counter, { initialState: { count: 0 } })
t.simulateAction('INCREMENT')
await t.waitForState(s => s.count === 1)
t.dispose()Render components to HTML strings on the server:
import { renderToString } from 'sygnal'
const html = renderToString(App, {
state: { count: 0 },
hydrateState: true, // embeds state for client hydration
})Auto-configures JSX and HMR with state preservation:
// vite.config.js
import sygnal from 'sygnal/vite'
export default defineConfig({ plugins: [sygnal()] })// src/main.js — just run, HMR is automatic
import { run } from 'sygnal'
import App from './App.jsx'
run(App)First-class Astro support with server rendering and client hydration:
// astro.config.mjs
import sygnal from 'sygnal/astro'
export default defineConfig({ integrations: [sygnal()] })---
import Counter from '../components/Counter.jsx'
---
<Counter client:load />File-based routing with SSR, client-side navigation, and automatic hydration:
// vite.config.js
import sygnal from 'sygnal/vite'
import vike from 'vike/plugin'
export default defineConfig({ plugins: [sygnal({ disableHmr: true }), vike()] })// pages/+config.js
import vikeSygnal from 'sygnal/config'
export default { extends: [vikeSygnal] }Pages are standard Sygnal components in pages/*/+Page.jsx. Supports layouts, data fetching, SPA mode, custom drivers, and ClientOnly for browser-only components.
Full type definitions included:
import type { RootComponent } from 'sygnal'
type State = { count: number }
type Actions = { INCREMENT: null }
const App: RootComponent<State, {}, Actions> = ({ state }) => (
<div>{state.count}</div>
)Vite (recommended):
// vite.config.js
export default defineConfig({
esbuild: {
jsx: 'automatic',
jsxImportSource: 'sygnal',
},
})For TypeScript projects, add to tsconfig.json:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "sygnal"
}
}Without JSX, use h() directly:
import { h } from 'sygnal'
h('div', [h('h1', 'Hello'), h('button.btn', 'Click')])📖 sygnal.js.org — Full guide, API reference, and examples.
| Example | Description |
|---|---|
| Getting Started | Interactive guide with live demos (Astro) |
| Kanban Board | Drag-and-drop with Collections and cross-component communication |
| Vike SSR | File-based routing with SSR, layouts, and data fetching |
| Advanced Features | Portals, slots, disposal, suspense, lazy loading |
| TypeScript 2048 | Full game in TypeScript |
| AI Discussion Panel | Complex SPA with custom drivers |
| Sygnal ToDoMVC | Live Demo |
| Sygnal 2048 | Live Demo |
| Sygnal Mahjong | Live Demo |
| Sygnal Calculator | Live Demo |
Sygnal's reactive architecture is built on patterns from Cycle.js by André Staltz. The Cycle.js runtime, DOM driver, state management, and isolation modules have been absorbed into the library — snabbdom, xstream, and extend are the only external dependencies.