Skip to content

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'),
},
}

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).

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 }),
}

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 })
},
},
}

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.

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.
  • signal is 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 is undefined where AbortController doesn’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(), not fetch in an EFFECT: the driver gives reply actions, latest, abort on unmount, and a test fake. Other promise-based APIs used from several places fit driverFromAsync().

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 ignored
App.model = {
BROKEN: {
EFFECT: (state) => ({ ...state, count: state.count + 1 }),
},
}