Skip to content

Sygnal for React Developers

You already know most of what a Sygnal component is made of: JSX, function components, props, keys, and a virtual DOM that patches the page. Here is a whole component. Type your name and wave:

function Hello({ state }) {
return (
<div>
<label>Your name <input className="name" value={state.name} /></label>
<p>Hello, {state.name || 'stranger'}!</p>
<button className="wave">Wave</button> {state.waves > 0 && <span>× {state.waves}</span>}
</div>
)
}
// Your useState defaults
Hello.initialState = { name: '', waves: 0 }
// Your onChange and onClick, in one place
Hello.intent = ({ DOM }) => ({
NAME: DOM.input('.name').value(),
WAVE: DOM.click('.wave'),
})
// Your setters, as pure functions
Hello.model = {
NAME: (state, name) => ({ ...state, name }),
WAVE: (state) => ({ ...state, waves: state.waves + 1 }),
}

The view is a plain function of state with no handlers in it. The intent says which events become which actions, and the model says what each action does to the state. That split is most of what’s new; the rest of this page maps the React habits you have onto it and explains the few places where Sygnal thinks differently. Every Sygnal sample on the page runs: edit it and the result updates.

Try it: press Edit and add a reset button. Put <button className="reset">Reset</button> in the view, RESET: DOM.click('.reset') in the intent, and RESET: (state) => ({ ...state, name: '', waves: 0 }) in the model. Three lines, one in each part, and nothing else to wire.

The short version: if you’ve used useReducer or Redux, you already know Sygnal’s core idea. A component’s state changes only through named actions handled by pure reducers. What’s new is that the rest follows the same rule: clicks, timers, fetches and messages to the parent are all actions too, so a component has no hooks and no effects.

  • JSX and function components. A view is a function of its props that returns JSX. className, style, key and fragments work as you expect.
  • Props. A parent passes props; the child reads them from its first argument: function Rating({ value, max }).
  • Familiar names: <Suspense> and lazy(), <Portal>, error boundaries (an onError on any component), refs (createRef()).
  • Vite and TypeScript. npm create sygnal-app scaffolds a Vite project; Vike and Astro cover server rendering.
In React you’d use…In SygnalGuide
useStateinitialState, plus a model entry per actionState, Model
useReducer, Redux reducersthe model: one reducer per action name, one state treeModel
onClick={…} in JSXan intent entry: DOM.click('.save')Intent
<input value onChange>value={state.x} + DOM.input('.x').value()Inputs
useMemo, derived valuescalculated fieldsCalculated Fields
useEffect(() => …, [])the BOOTSTRAP actionModel
an effect’s cleanupthe DISPOSE actionDisposal Hooks
useEffect for a side effectan EFFECT sink, or a driverEffect Handlers, Drivers
setInterval in an effectthe timers staticTimers
fetch in an effect, React Query’s useQuerythe resources staticResources and Caching
a mutation, setQueryDataan HTTP sink with reply actions, updates, invalidatesHTTP
callback props (onRemove)the child’s PARENT sink, CHILD.select(Child) in the parentParent-Child
lifting state upthe state is already up: one tree, and state="key" gives a child a sliceState
items.map(i => <Item key={i.id} />)<Collection of={Item} from="items" />Collections
createContext, useContextthe .context staticContext
a store shared by distant componentsthe EVENTS busDrivers
custom hooksbehaviors (defineBehavior, uses)Behaviors
useRef + .focus()autoFocus, the ELEMENT sink, createRef()Inputs, Element Commands, Refs
createPortal<Portal>Portals
React.lazy + <Suspense>lazy() + <Suspense>Lazy Loading, Suspense
an error boundary class.onError on any componentError Boundaries
React Hook Form, Formikthe form behaviorForms
React RoutermakeRouter() and the route staticRouter
localStorage in an effectpersist()Persistence
React Testing LibraryrenderComponent()Testing
Next.jsVike or AstroVike, Astro
one React component you can’t replacefromReact()Adapters

The rest of the page covers the five differences behind most of that table.

1. Events are named in intent, not bound in JSX

Section titled “1. Events are named in intent, not bound in JSX”

In React, a handler is attached to the element in the JSX:

React
function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<button onClick={() => setCount((c) => c - 1)}>−</button>
<span>{count}</span>
<button onClick={() => setCount((c) => c + 1)}>+</button>
</div>
)
}

In Sygnal the view only describes the page. The component’s intent names each action and the event that triggers it, and its model says what each action does to the state:

Sygnal
function Counter({ state }) {
return (
<div>
<button className="dec" aria-label="Decrement">−</button>
<span>{state.count}</span>
<button className="inc" aria-label="Increment">+</button>
</div>
)
}
Counter.initialState = { count: 0 }
Counter.intent = ({ DOM }) => ({
DEC: DOM.click('.dec'),
INC: DOM.click('.inc'),
})
Counter.model = {
DEC: (state) => ({ ...state, count: state.count - 1 }),
INC: (state) => ({ ...state, count: state.count + 1 }),
}
  • The class is the link between the view and the intent: DOM.click('.inc') hears clicks on the element with className="inc".
  • The view is a plain function of state. It creates no closures and holds no state; it can be rendered on a server or in a test without running anything else.
  • An action is data. INC is a name with an optional value; it shows up in the devtools action log, and a test can dispatch it directly.

2. State changes are reducers, keyed by action

Section titled “2. State changes are reducers, keyed by action”

With useState, each setter is called from the handler that needs it. A Sygnal component has one model: for each action name, a pure function from the current state to the next one, like a useReducer reducer split by action type.

React
function Todos() {
const [draft, setDraft] = useState('')
const [todos, setTodos] = useState([])
const add = () => {
if (!draft.trim()) return
setTodos([...todos, { id: Date.now(), text: draft.trim(), done: false }])
setDraft('')
}
return (
<div>
<label>New todo <input value={draft} onChange={(e) => setDraft(e.target.value)} /></label>
<button onClick={add}>Add</button>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<label>
<input type="checkbox" checked={todo.done}
onChange={() => setTodos(todos.map((t) => (t.id === todo.id ? { ...t, done: !t.done } : t)))} />
{todo.text}
</label>
</li>
))}
</ul>
</div>
)
}
Sygnal
import { ABORT } from 'sygnal'
function Todos({ state }) {
return (
<div>
<label>New todo <input className="draft" value={state.draft} /></label>
<button className="add">Add</button>
<ul>
{state.todos.map((todo) => (
<li key={todo.id}>
<label>
<input type="checkbox" className="done" data={{ id: todo.id }} checked={todo.done} />
{todo.text}
</label>
</li>
))}
</ul>
<p>{state.todos.filter((todo) => !todo.done).length} left</p>
</div>
)
}
Todos.initialState = { draft: '', nextId: 1, todos: [] }
Todos.intent = ({ DOM }) => ({
DRAFT: DOM.input('.draft').value(),
ADD: DOM.click('.add'),
TOGGLE: DOM.change('.done').data('id', Number),
})
Todos.model = {
DRAFT: (state, draft) => ({ ...state, draft }),
ADD: (state) => {
const text = state.draft.trim()
if (!text) return ABORT
return { ...state, draft: '', nextId: state.nextId + 1, todos: [...state.todos, { id: state.nextId, text, done: false }] }
},
TOGGLE: (state, id) => ({
...state,
todos: state.todos.map((todo) => (todo.id === id ? { ...todo, done: !todo.done } : todo)),
}),
}
  • A controlled input works like React’s: value={state.draft} writes the value on every render, and the DRAFT action keeps the state in step as the user types.
  • data={{ id }} and .data('id', Number) carry the row’s id from the view to the action, where React would close over todo.id in a handler.
  • ABORT means “no change”: nothing re-renders.
  • The second argument of a reducer is the action’s value: the text for DRAFT, the id for TOGGLE.

A React component reaches the outside world from effects. A Sygnal component never does: it declares what it needs, or returns a request from a model entry, and a driver does the work and sends the result back as an action. Drivers are set up once in run(App, drivers); on this page the demos get an HTTP and a TIMER driver automatically.

React
function Quote() {
const [id, setId] = useState(1)
const [quote, setQuote] = useState({ status: 'loading' })
useEffect(() => {
const controller = new AbortController()
setQuote({ status: 'loading' })
fetch(`/api/quotes/${id}`, { signal: controller.signal })
.then((res) => (res.ok ? res.json() : Promise.reject(new Error(res.statusText))))
.then((data) => setQuote({ status: 'success', data }))
.catch((error) => error.name !== 'AbortError' && setQuote({ status: 'error', error }))
return () => controller.abort()
}, [id])
return (
<div>
<button onClick={() => setId(id + 1)}>Next</button>
<p>{quote.status === 'loading' ? 'Loading…' : quote.status === 'error' ? 'No such quote.' : quote.data.text}</p>
</div>
)
}

A Sygnal component declares the request it needs, computed from its state, in its resources. The fetch driver sends it, aborts it when the request changes, and keeps state.quote up to date. A stand-in server answers for quotes 1 to 3:

Demo server
const quotes = { 1: 'Start where you are.', 2: 'Less, but better.', 3: 'Done is done.' }
export default {
'GET /api/quotes/:id': ({ params }) => (quotes[params.id]
? { json: { id: Number(params.id), text: quotes[params.id] }, delayMs: 800 }
: { status: 404, json: { message: 'No such quote' }, delayMs: 400 }),
}
Sygnal
function Quote({ state }) {
const { status, data } = state.quote
return (
<div>
<button className="next">Next</button>
<p>{status === 'loading' ? 'Loading…' : status === 'error' ? 'No such quote.' : data?.text}</p>
</div>
)
}
Quote.initialState = { id: 1 }
Quote.resources = {
quote: (state) => `/api/quotes/${state.id}`,
}
Quote.intent = ({ DOM }) => ({ NEXT: DOM.click('.next') })
Quote.model = {
NEXT: (state) => ({ ...state, id: state.id + 1 }),
}

Click Next past quote 3 to see the error state, or click it twice quickly: the first request is aborted.

  • state.quote is { status, data, error, refreshing }: no loading flags to write.
  • Add queryCache() to the driver for what React Query adds: caching, request sharing, refetch on focus. See Resources and Caching.
  • A write (a POST, a PUT) is an HTTP sink in a model entry, with actions for the reply. See HTTP.
React
function Timer() {
const [running, setRunning] = useState(false)
const [seconds, setSeconds] = useState(0)
useEffect(() => {
if (!running) return
const id = setInterval(() => setSeconds((s) => s + 1), 1000)
return () => clearInterval(id)
}, [running])
return (
<div>
<p>{seconds} s</p>
<button onClick={() => setRunning(!running)}>{running ? 'Pause' : 'Start'}</button>
</div>
)
}
Sygnal
import { toggle } from 'sygnal'
function Timer({ state }) {
return (
<div>
<p>{state.seconds} s</p>
<button className="toggle">{state.running ? 'Pause' : 'Start'}</button>
</div>
)
}
Timer.initialState = { running: false, seconds: 0 }
Timer.timers = (state) => ({
tick: state.running && { every: 1000, action: 'TICK' },
})
Timer.intent = ({ DOM }) => ({ TOGGLE: DOM.click('.toggle') })
Timer.model = {
TOGGLE: toggle('running'),
TICK: (state) => ({ ...state, seconds: state.seconds + 1 }),
}

Like resources, timers is computed from the state: while state.running is true there’s an interval, and when it turns false the interval stops. The component also stops it when it unmounts. See Timers.

ReactSygnal
useEffect(() => { … }, []) to start somethingthe BOOTSTRAP action: a model entry that runs once after the first render
localStorage read in useState, written in useEffectApp.persist = persist({ key: 'app', pick: ['todos'] }) (Persistence)
document.title = … in an effectthe head static (Document Head)
a one-off imperative call (analytics, clipboard)an EFFECT sink on a model entry (Effect Handlers)
ref.current.focus()ELEMENT: { focus: '.title' } on a model entry (Element Commands)
a WebSocket in an effectthe connections static (Sockets)

In React, a child reports to its parent by calling a function prop:

React
function TaskItem({ task, onPick, onRemove }) {
return (
<li>
{task.title} <button onClick={() => onPick(task.id)}>Pick</button>
<button onClick={() => onRemove(task.id)}>Remove</button>
</li>
)
}
function TaskList() {
const [tasks, setTasks] = useState([{ id: 1, title: 'Write' }, { id: 2, title: 'Test' }, { id: 3, title: 'Ship' }])
const [picked, setPicked] = useState(null)
return (
<div>
<p>Picked: {picked ?? 'none'}</p>
<ul>
{tasks.map((task) => (
<TaskItem key={task.id} task={task} onPick={setPicked}
onRemove={(id) => setTasks(tasks.filter((t) => t.id !== id))} />
))}
</ul>
</div>
)
}

A Sygnal child sends a value up through its PARENT sink, and the parent reads it with CHILD.select(Child) in its intent. A list of components is a <Collection>: each item gets one element of the array as its state, and an item removes itself by returning undefined:

Sygnal
import { Collection } from 'sygnal'
function TaskItem({ state }) {
return (
<li>
{state.title} <button className="pick">Pick</button> <button className="remove">Remove</button>
</li>
)
}
TaskItem.intent = ({ DOM }) => ({
PICK: DOM.click('.pick'),
REMOVE: DOM.click('.remove'),
})
TaskItem.model = {
PICK: { PARENT: (state) => ({ id: state.id }) },
REMOVE: () => undefined,
}
function TaskList({ state }) {
return (
<div>
<p>Picked: {state.picked ?? 'none'}</p>
<ul>
<Collection of={TaskItem} from="tasks" />
</ul>
</div>
)
}
TaskList.initialState = {
picked: null,
tasks: [{ id: 1, title: 'Write' }, { id: 2, title: 'Test' }, { id: 3, title: 'Ship' }],
}
TaskList.intent = ({ CHILD }) => ({
PICKED: CHILD.select(TaskItem),
})
TaskList.model = {
PICKED: (state, { id }) => ({ ...state, picked: id }),
}
export default TaskList
  • A parent can’t listen to a child’s elements. DOM.click('.remove') in TaskList would never fire: a component’s DOM sees only the elements its own view renders. Handle the click in the child and send the result up. In development, Sygnal reports this mistake as SYG104.
  • Items are keyed by id, so you don’t write key props for a Collection. filter and sort props change what’s shown without touching the array.
  • Siblings far apart (a toolbar and a status bar) talk through the EVENTS bus instead of a shared store: one sends event('SAVED', data), the other listens with EVENTS.select('SAVED'). See Drivers.
  • Props still flow down as usual: <TaskItem> inside a Collection gets its item as state, and any other component gets the props you pass.

In React, state lives in the component that owns it, and you lift it up when two components need it. In Sygnal there’s one state tree, kept by the root component, and a child sees the parent’s state, or a slice of it with state="key". A child can change its slice directly, and the parent sees the change at once: no callback, no lifting.

React
const LimitContext = createContext(false)
function Stepper({ label, value, onChange }) {
const full = useContext(LimitContext)
return (
<p>
{label}: {value}{' '}
<button aria-label={`Fewer ${label}`} disabled={value === 0} onClick={() => onChange(value - 1)}>−</button>
<button aria-label={`More ${label}`} disabled={full} onClick={() => onChange(value + 1)}>+</button>
</p>
)
}
function Booking() {
const [adults, setAdults] = useState(2)
const [kids, setKids] = useState(0)
const guests = useMemo(() => adults + kids, [adults, kids])
return (
<LimitContext.Provider value={guests >= 6}>
<Stepper label="Adults" value={adults} onChange={setAdults} />
<Stepper label="Children" value={kids} onChange={setKids} />
<p>{guests} guests{guests >= 6 && ' (the maximum)'}</p>
</LimitContext.Provider>
)
}
Sygnal
function Stepper({ state, context, label }) {
return (
<p>
{label}: {state.count}{' '}
<button className="dec" aria-label={`Fewer ${label}`} disabled={state.count === 0}>−</button>
<button className="inc" aria-label={`More ${label}`} disabled={context.full}>+</button>
</p>
)
}
Stepper.intent = ({ DOM }) => ({
DEC: DOM.click('.dec'),
INC: DOM.click('.inc'),
})
Stepper.model = {
DEC: (state) => ({ ...state, count: state.count - 1 }),
INC: (state) => ({ ...state, count: state.count + 1 }),
}
function Booking({ state }) {
return (
<div>
<Stepper state="adults" label="Adults" />
<Stepper state="kids" label="Children" />
<p>{state.guests} guests{state.guests >= 6 && ' (the maximum)'}</p>
</div>
)
}
Booking.initialState = { adults: { count: 2 }, kids: { count: 0 } }
Booking.calculated = {
guests: (state) => state.adults.count + state.kids.count,
}
Booking.context = {
full: (state) => state.guests >= 6,
}
export default Booking
  • state="adults" gives the first Stepper state.adults as its state. Its reducers read and return that slice; Sygnal writes it back into the parent’s state.
  • calculated is useMemo for state: state.guests is recomputed when the state changes, and reads like any other field.
  • .context is a provider without the wrapper: values computed from this component’s state, readable by every component below it through context. It can use calculated fields: full reads state.guests.
  • A child doesn’t declare initialState: the parent’s state holds its start values (adults: { count: 2 }). A child that does is reported as SYG405. For state that really is private to a component, see isolatedState.

React Testing Library tests drive the DOM. renderComponent can do that too, and it can also dispatch actions and answer requests directly, without a mock server:

React
test('shows the next quote', async () => {
server.use(http.get('/api/quotes/2', () => HttpResponse.json({ id: 2, text: 'Less, but better.' })))
render(<Quote />)
await userEvent.click(screen.getByRole('button', { name: 'Next' }))
expect(await screen.findByText('Less, but better.')).toBeInTheDocument()
})
Sygnal
import { it, expect } from 'vitest'
import { renderComponent } from 'sygnal'
import Quote from './Quote.jsx'
it('shows the next quote', async () => {
const t = renderComponent(Quote)
t.simulateEvent('.next', 'click')
await t.respond('HTTP', { id: 2, text: 'Less, but better.' }, 'quote')
expect(t.html()).toContain('Less, but better.')
t.dispose()
})

The test’s HTTP driver is the real fetch driver over an in-memory network: each request waits until the test answers it with t.respond or t.fail. See Testing.

These come from watching developers and coding agents write their first Sygnal components. Sygnal’s development diagnostics catch most of them, in the browser console and in sygnal-check:

HabitWhat happensInstead
onClick={…} on an elementNothing: Sygnal attaches no listenerA class on the element and an intent entry
The parent selects a button that a child rendersThe action never fires (SYG104)Handle it in the child, send it up with PARENT
A button passed to a child as children, handled in the parent’s intentThe action never fires (SYG104): the child renders it, so the child’s intent hears itHandle it in the child and send it up with PARENT, or keep the button outside the child
A class in the view that doesn’t match the intent’s selectorThe action never fires (SYG110)Keep the names in step; sygnal-check compares them
value={state.x} without an input actionTyping is undone on the next render (SYG111)DOM.input('.x').value() and a reducer
A child with its own initialStateAn error (SYG405)Put its start values in the parent’s state
state.todos.push(todo); return stateThe change is ignored (SYG222)Return a new object: { ...state, todos: [...state.todos, todo] }
An HTTP or PARENT sink that reads what STATE just setIt sees the state from before the actionCompute the value from (state, data) in each sink (Model)

You don’t have to move everything at once. Two adapters work in opposite directions:

  • A React component inside a Sygnal app: fromReact(Component) from sygnal/react makes a JSX tag that renders it. Use it for the one component you can’t replace yet; it brings React’s runtime with it. See Adapters.
  • A Sygnal component inside a React app: defineElement('task-list', TaskList) from sygnal/element publishes the component as a custom element. React renders <task-list> like any other tag, and React 19 passes props to it as properties. See Web Components.
  • Port an App: a small app built step by step, with the React and Vue code for each step.
  • Quick Start: set up a project.
  • Architecture: how intent, model and view fit together, and why.