Skip to content

Model

The .model property defines what happens for each action. It maps action names (the keys your intent returns) to reducers. Every intent action needs a model entry with the same name, and every model entry needs something that triggers it; otherwise Sygnal reports SYG101 or SYG102 in dev.

The most common case is updating state. A function given directly as the entry is a STATE reducer: it receives the current state and returns the new one.

MyComponent.model = {
INCREMENT: (state) => ({ ...state, count: state.count + 1 }),
SET_NAME: (state, name) => ({ ...state, name }),
}

Always return a new object and spread the previous state. A reducer that drops keys is reported as SYG201, and one that changes the state in place as SYG222.

Every reducer receives four arguments:

  1. state: the current component state (including calculated fields)
  2. data: the value emitted by the action’s stream in intent
  3. next: a function that dispatches another action (see Chaining Actions)
  4. props: the props the parent passed, plus context, children, slots and uid
Item.model = {
SELECT: (state, data, next, props) => ({ ...state, selected: props.selectedId === state.id }),
}

The set() and toggle() helpers build common STATE reducers:

import { set, toggle } from 'sygnal'
Panel.model = {
OPEN: set({ isOpen: true }),
RENAME: set((state, title) => ({ title })),
TOGGLE_HELP: toggle('showHelp'),
}

set() takes an object or a function, never a field name: set('title') doesn’t store the action data in title (SYG221). Use set((state, title) => ({ title })).

Return ABORT from a STATE reducer to mean “no change”. The state update is skipped entirely:

import { ABORT } from 'sygnal'
MyComponent.model = {
MOVE: (state, position) => state.locked ? ABORT : { ...state, position },
}

Returning the state object the reducer received means the same thing: no state is emitted and nothing re-renders, so (state, x) => x ? { ...state, x } : state is a no-op when x is empty. The docs use ABORT because it says so explicitly. (Before 6.0, returning state produced a new state and strict mode flagged it as SYG502; that rule is retired.) One exception to “nothing re-renders”: an action triggered by typing in a field (an input or change event) re-renders its component, so a controlled field that the reducer refused shows the state’s value again (see Controlled Inputs).

Because the same object means “no change”, a reducer that changes the state in place and returns it has no effect. In dev, Sygnal reports that as SYG222. Return a new object:

Todo.model = {
// SYG222: the same object comes back, so the change is ignored
// DONE: (state) => { state.done = true; return state },
DONE: (state) => ({ ...state, done: true }),
}

Returning undefined is different: in a Collection item it removes the item, and in a root component it wipes the state (SYG202).

Deep updates by spreading get long. Immer’s curried produce() lets you write them as mutations of a draft, and returns a new object, or the original one when the recipe changed nothing, which Sygnal then treats as “no change”. A produce() recipe is a STATE reducer as it is:

import { produce } from 'immer'
Todos.model = {
ADD: produce((draft, text) => {
draft.todos.push({ id: draft.nextId, text, done: false })
draft.nextId += 1
}),
TOGGLE: produce((draft, id) => {
const todo = draft.todos.find(t => t.id === id)
if (todo) todo.done = !todo.done
}),
}

The recipe gets the reducer’s arguments: (draft, data, next, props). Immer is not bundled with Sygnal; install it yourself (npm install immer). By default Immer freezes the objects it returns, so another reducer that changes them in place throws instead of being silently ignored.

When an action does more than update state, give the entry an object whose keys are sinks. Each sink gets its own reducer, with the same four arguments:

import { event } from 'sygnal'
MyComponent.model = {
SAVE: {
STATE: (state) => ({ ...state, saved: true }),
EVENTS: event('SAVED', (state) => ({ id: state.id })),
LOG: (state) => `Saved ${state.id}`,
},
}

Use the object form even for a single non-STATE sink:

TaskCard.model = {
DELETE: {
PARENT: (state) => ({ taskId: state.id }),
},
}

The built-in sinks are:

SinkSends
STATEThe new state (same as a plain reducer)
EVENTSA global event, usually built with event() (see below)
PARENTA value to the parent component (see Parent-Child Communication)
EFFECTNothing; runs a side effect (see below)
READYA readiness flag for Suspense
LOGAny value, logged to the console

Any custom driver passed to run() is a sink as well, by its name (for example API or DND).

A non-STATE reducer can return ABORT to send nothing.

Every sink of one action receives the same state: the result of all earlier actions, before this action’s own STATE reducer runs. EVENTS, PARENT, EFFECT and driver sinks never see what STATE returns for the same action. To send the updated value, compute it from (state, data) inside the sink:

import { event } from 'sygnal'
Counter.model = {
INC: {
STATE: (state) => ({ ...state, count: state.count + 1 }),
EVENTS: event('COUNT_CHANGED', (state) => ({ count: state.count + 1 })), // not state.count: that's the old value
PARENT: (state, data, next, props) => ({ id: props.id, count: state.count + 1 }),
},
}

If several sinks need the same derived value, put it in a helper function that each of them calls with (state, data).

The same holds for requests: when one action sets state.id and sends a request for it, build the URL from data, not state (HTTP: (state, id) => ({ url: `/api/quotes/${id}`, ok: 'LOADED' })). See HTTP.

event(type, payload?) returns an EVENTS sink function that puts { type, data } on the global event bus:

import { event } from 'sygnal'
Lane.model = {
DELETE_LANE: {
STATE: (state) => ({ ...state, deleting: true }),
EVENTS: event('DELETE_LANE', (state) => ({ laneId: state.id })),
},
RESET_ALL: {
EVENTS: event('RESET'), // no payload
},
DARK_MODE: {
EVENTS: event('SET_THEME', 'dark'), // static payload
},
}

The payload function receives the reducer arguments (state, data, next, props). Any component receives the event with EVENTS.select('DELETE_LANE') in its intent. With TypeScript, the SygnalEvents registry checks event names and payloads.

The EFFECT sink runs a function for its side effect only: no state change, nothing sent to a driver.

App.model = {
PLAY: {
EFFECT: () => playerCmd.send('play'),
},
ROUTE: {
EFFECT: (state, data, next) => next(state.mode === 'a' ? 'DO_A' : 'DO_B', data),
},
}

Don’t return a value from an EFFECT handler (it’s ignored, and Sygnal warns with SYG219). See Effect Handlers.

Setting a sink to true sends the action’s data as-is:

MyComponent.model = {
LOG_DATA: {
LOG: true,
},
}

next(action, data?, delay?) dispatches another action of the same component. Call it from an EFFECT handler, or from a STATE reducer that also changes state:

MyComponent.model = {
SUBMIT: (state, data, next) => {
next('VALIDATE', data)
return { ...state, submitting: true }
},
START: {
EFFECT: (state, data, next) => next('TICK', null, 1000), // after 1 second
},
VALIDATE: (state, data) => ({ ...state, valid: Boolean(data) }),
TICK: (state) => ({ ...state, ticks: state.ticks + 1 }),
}

Without a delay, the follow-up action runs about 10 ms later. You can call next() several times in one reducer. The delay must be a number of milliseconds (SYG215).

Sygnal dispatches these actions itself; add a model entry to react to them:

ActionWhen It Fires
BOOTSTRAPOnce, when the component is instantiated (like React’s useEffect(() => {}, []))
INITIALIZEWhen the component receives its first state. STATE sink only (SYG210)
DISPOSEWhen the component is about to unmount (see Disposal Hooks)

HYDRATE is no longer a built-in action (6.0): nothing dispatches it, and it is an ordinary action name. Server data reaches the state through Vike’s +data or the hydrateState option.

MyComponent.model = {
BOOTSTRAP: {
LOG: () => 'Component mounted!',
EFFECT: (state, data, next) => next('LOAD_DATA'),
},
INITIALIZE: (state) => ({ ...state, startedAt: Date.now() }),
DISPOSE: {
EFFECT: () => console.log('Component unmounting'),
},
}