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 defaultsHello.initialState = { name: '', waves: 0 }
// Your onChange and onClick, in one placeHello.intent = ({ DOM }) => ({ NAME: DOM.input('.name').value(), WAVE: DOM.click('.wave'),})
// Your setters, as pure functionsHello.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.
What carries over
Section titled “What carries over”- JSX and function components. A view is a function of its props that returns JSX.
className,style,keyand 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>andlazy(),<Portal>, error boundaries (anonErroron any component), refs (createRef()). - Vite and TypeScript.
npm create sygnal-appscaffolds a Vite project; Vike and Astro cover server rendering.
At a glance
Section titled “At a glance”| In React you’d use… | In Sygnal | Guide |
|---|---|---|
useState | initialState, plus a model entry per action | State, Model |
useReducer, Redux reducers | the model: one reducer per action name, one state tree | Model |
onClick={…} in JSX | an intent entry: DOM.click('.save') | Intent |
<input value onChange> | value={state.x} + DOM.input('.x').value() | Inputs |
useMemo, derived values | calculated fields | Calculated Fields |
useEffect(() => …, []) | the BOOTSTRAP action | Model |
| an effect’s cleanup | the DISPOSE action | Disposal Hooks |
useEffect for a side effect | an EFFECT sink, or a driver | Effect Handlers, Drivers |
setInterval in an effect | the timers static | Timers |
fetch in an effect, React Query’s useQuery | the resources static | Resources and Caching |
a mutation, setQueryData | an HTTP sink with reply actions, updates, invalidates | HTTP |
callback props (onRemove) | the child’s PARENT sink, CHILD.select(Child) in the parent | Parent-Child |
| lifting state up | the state is already up: one tree, and state="key" gives a child a slice | State |
items.map(i => <Item key={i.id} />) | <Collection of={Item} from="items" /> | Collections |
createContext, useContext | the .context static | Context |
| a store shared by distant components | the EVENTS bus | Drivers |
| custom hooks | behaviors (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 component | Error Boundaries |
| React Hook Form, Formik | the form behavior | Forms |
| React Router | makeRouter() and the route static | Router |
localStorage in an effect | persist() | Persistence |
| React Testing Library | renderComponent() | Testing |
| Next.js | Vike or Astro | Vike, Astro |
| one React component you can’t replace | fromReact() | 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:
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:
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 withclassName="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.
INCis 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.
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> )}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 theDRAFTaction 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 overtodo.idin a handler.ABORTmeans “no change”: nothing re-renders.- The second argument of a reducer is the action’s value: the text for
DRAFT, the id forTOGGLE.
3. Side effects are sinks, not hooks
Section titled “3. Side effects are sinks, not hooks”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.
Fetching data
Section titled “Fetching data”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:
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 }),}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.quoteis{ 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
HTTPsink in a model entry, with actions for the reply. See HTTP.
Timers
Section titled “Timers”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> )}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.
Other effects
Section titled “Other effects”| React | Sygnal |
|---|---|
useEffect(() => { … }, []) to start something | the BOOTSTRAP action: a model entry that runs once after the first render |
localStorage read in useState, written in useEffect | App.persist = persist({ key: 'app', pick: ['todos'] }) (Persistence) |
document.title = … in an effect | the 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 effect | the connections static (Sockets) |
4. Data goes up as actions, not callbacks
Section titled “4. Data goes up as actions, not callbacks”In React, a child reports to its parent by calling a function prop:
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:
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')inTaskListwould never fire: a component’sDOMsees 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 writekeyprops for a Collection.filterandsortprops change what’s shown without touching the array. - Siblings far apart (a toolbar and a status bar) talk through the
EVENTSbus instead of a shared store: one sendsevent('SAVED', data), the other listens withEVENTS.select('SAVED'). See Drivers. - Props still flow down as usual:
<TaskItem>inside a Collection gets its item asstate, and any other component gets the props you pass.
5. One state tree; children get slices
Section titled “5. One state tree; children get slices”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.
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> )}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 Bookingstate="adults"gives the first Stepperstate.adultsas its state. Its reducers read and return that slice; Sygnal writes it back into the parent’s state.calculatedisuseMemofor state:state.guestsis recomputed when the state changes, and reads like any other field..contextis a provider without the wrapper: values computed from this component’s state, readable by every component below it throughcontext. It can use calculated fields:fullreadsstate.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, seeisolatedState.
Testing
Section titled “Testing”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:
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()})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.
Mistakes React developers make
Section titled “Mistakes React developers make”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:
| Habit | What happens | Instead |
|---|---|---|
onClick={…} on an element | Nothing: Sygnal attaches no listener | A class on the element and an intent entry |
| The parent selects a button that a child renders | The 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 intent | The action never fires (SYG104): the child renders it, so the child’s intent hears it | Handle 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 selector | The action never fires (SYG110) | Keep the names in step; sygnal-check compares them |
value={state.x} without an input action | Typing is undone on the next render (SYG111) | DOM.input('.x').value() and a reducer |
A child with its own initialState | An error (SYG405) | Put its start values in the parent’s state |
state.todos.push(todo); return state | The change is ignored (SYG222) | Return a new object: { ...state, todos: [...state.todos, todo] } |
An HTTP or PARENT sink that reads what STATE just set | It sees the state from before the action | Compute the value from (state, data) in each sink (Model) |
Bringing Sygnal into a React codebase
Section titled “Bringing Sygnal into a React codebase”You don’t have to move everything at once. Two adapters work in opposite directions:
- A React component inside a Sygnal app:
fromReact(Component)fromsygnal/reactmakes 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)fromsygnal/elementpublishes 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.
Where next
Section titled “Where next”- 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.