Effect Handlers
Use the EFFECT sink for model entries that only need to run side effects — sending commands, calling next(), or awaiting an async API — without producing a state change or emitting to any driver.
App.model = { SEND_COMMAND: { EFFECT: () => playerCmd.send('play'), },}Why EFFECT?
Section titled “Why EFFECT?”Before EFFECT, a side-effect-only entry had to be a STATE reducer that ran the effect and returned ABORT to skip the state update. That still works, but strict mode flags it (SYG503): EFFECT makes the intent explicit, so STATE reducers stay pure. ABORT remains the canonical way for a STATE reducer to say “no change” (see Model).
Using next()
Section titled “Using next()”EFFECT handlers receive the same arguments as regular reducers — (state, data, next, props) — so you can use next() to dispatch follow-up actions:
App.model = { ROUTE: { EFFECT: (state, data, next) => { if (state.mode === 'a') next('DO_A', data) else next('DO_B', data) }, }, DO_A: (state, data) => ({ ...state, resultA: data }), DO_B: (state, data) => ({ ...state, resultB: data }),}Reading State
Section titled “Reading State”The current state (including calculated fields) is available as the first argument, just like regular reducers:
App.model = { LOG_STATE: { EFFECT: (state) => { analytics.track('page_view', { page: state.currentPage }) }, },}Combining with Other Sinks
Section titled “Combining with Other Sinks”EFFECT can be combined with other sinks in the same action to run side effects alongside state updates or driver emissions:
import { event } from 'sygnal'
App.model = { SUBMIT: { STATE: (state) => ({ ...state, submitting: true }), EFFECT: () => analyticsCmd.send('track', { event: 'submit' }), EVENTS: event('FORM_SUBMITTED', (state) => state.formData), },}Every sink of one action receives the same state: the result of all earlier actions, before this action’s own STATE reducer runs. So EFFECT and EVENTS above see submitting as it was, not true. Pass anything they need through the action’s data, or compute it from the same inputs.
Async Work That Isn’t HTTP
Section titled “Async Work That Isn’t HTTP”An EFFECT can be async: await the work, then dispatch the result with next(). The fourth argument has a signal, an AbortSignal that is aborted when the component unmounts:
Editor.model = { COPY: { EFFECT: async (state, data, next, { signal }) => { await navigator.clipboard.writeText(state.text) if (!signal?.aborted) next('COPIED') }, }, COPIED: (state) => ({ ...state, copied: true }),}- A rejection (or a throw before the first
await) is reported as SYG214, like a synchronous throw, with no unhandled rejection; the app keeps running. Catch errors you want to show and dispatch them:catch (error) { next('COPY_FAILED', String(error)) }. next()after the component unmounted is ignored.signalis the same for every EFFECT of one instance. Pass it to APIs that accept one (fetch,addEventListener, many SDKs) so their work stops on unmount. It isundefinedwhereAbortControllerdoesn’t exist.- There is no concurrency control: two clicks run two EFFECTs, and both results arrive. Work that needs “latest only”, cancellation or retries belongs in a driver.
- HTTP goes through
makeFetchDriver(), notfetchin an EFFECT: the driver gives reply actions,latest, abort on unmount, and a test fake. Other promise-based APIs used from several places fitdriverFromAsync().
Return Value Warning
Section titled “Return Value Warning”EFFECT handlers should not return a value — any return value is ignored. If a value other than a promise is returned, a console warning (SYG219) is emitted to help catch mistakes where a reducer was accidentally placed in an EFFECT sink instead of a STATE sink. A returned promise (an async EFFECT) is expected and isn’t reported.
// ⚠️ This will log a warning — the returned state is ignoredApp.model = { BROKEN: { EFFECT: (state) => ({ ...state, count: state.count + 1 }), },}