preact-signal-effects
Signal side effects for
Preact signals:
a declarative createSideEffect over any signals, a
lifecycle-managed runner, and a
redux-compatible effects middleware with a typed
createActionEffect. Standalone and framework-agnostic
— works with
preact-signal-redux, plain redux / redux-toolkit, or no store at all.
Signals are great at deriving state, but real apps also need
effects: persist to IndexedDB, call an API, retry a
failed upload. This package gives those effects a first-class
lifecycle (run / stop, grouped runners)
and — when you use an action-driven store — a clean bridge from
dispatched actions to typed effect handlers, without any global
mutable actions$.
-
createSideEffect(...signals, fn)— subscribe to N signals, get their values as a typed tuple, microtask-deferred. -
createSideEffectsRunner()— start/stop groups of effects together (per feature, per page). -
createEffectsMiddleware()— a classic curried redux middleware that publishes every dispatched action into a localactions$signal. -
createActionEffect(actions$, creators, handler)— effects filtered by action creator(s), with fully typed payloads.
Getting started
Install#
npm install @dmytromykhailiuk/preact-signal-effects @preact/signals-core
The only peer dependency is @preact/signals-core —
the primitives @preact/signals itself is built on,
so any Preact app already satisfies it. There is
no dependency on any store library: the package
is fully standalone, and its redux compatibility is purely
structural — the middleware and action-creator types describe
shapes, they import nothing from redux, redux-toolkit or
preact-signal-redux.
Quick start#
A side effect over plain signals — no store involved. Pass any number of signals followed by a callback; the callback receives their current values as a typed tuple.
import { signal } from "@preact/signals-core";
import { createSideEffect } from "@dmytromykhailiuk/preact-signal-effects";
const user$ = signal({ name: "Ada" });
const theme$ = signal<"light" | "dark">("light");
const persistPrefs = createSideEffect(user$, theme$, (user, theme) => {
localStorage.setItem("prefs", JSON.stringify({ user, theme }));
});
const stop = persistPrefs.run(); // fires now with current values, then on every change
theme$.value = "dark"; // → persisted in a microtask
stop(); // or persistPrefs.stop()
persistPrefs.run(); // ...and it starts right back up
run() subscribes and returns the stop
function; stop() unsubscribes. That is the whole
surface — everything else in this package builds on it.
Core model
Concepts#
createSideEffect returns a SideEffect —
a small lifecycle object:
interface SideEffect {
run(options?: RunOptions): () => void; // idempotent; returns stop
stop(): void; // unsubscribes + cancels pending invocations
readonly isRunning: boolean; // whether currently subscribed
}
-
run()is idempotent. Calling it while the effect is already running is a no-op that simply returns thestopfunction again. -
stop()cancels pending work. Because callbacks are microtask-deferred, an invocation can be scheduled but not yet flushed —stop()unsubscribes and cancels those pending invocations, so nothing fires after you stopped. -
run()/stop()cycle freely. An effect can be started and stopped any number of times, in any order.
The signals and the callback are extracted once at creation time
and never mutated afterwards. This fixes a whole class of bugs
where an effect works until the first stop() and
then silently never runs again — here the third, tenth and
hundredth run() behave exactly like the first.
Microtask semantics#
Signal values are captured synchronously at the moment of the write — so the tuple your callback receives is always internally consistent — but the callback itself is deferred to a microtask:
-
Writes inside
batch()collapse into a single invocation with the final values. - N separate synchronous writes produce N invocations, each with the values captured at its write time.
- Your callback never runs in the middle of a signal write — by the time it fires, the write (and everything synchronous around it) has completed.
import { batch, signal } from "@preact/signals-core";
import { createSideEffect } from "@dmytromykhailiuk/preact-signal-effects";
const a$ = signal(1);
const b$ = signal(10);
const logEffect = createSideEffect(a$, b$, (a, b) => console.log(a, b));
logEffect.run({ immediate: false });
// One batch → ONE invocation with the final values:
batch(() => {
a$.value = 2;
b$.value = 20;
});
// microtask flush → logs "2 20" once
// Two separate writes → TWO invocations, values captured at write time:
a$.value = 3; // captures [3, 20]
b$.value = 30; // captures [3, 30]
// microtask flush → logs "3 20", then "3 30"
In other words: the values are snapshotted eagerly, the work happens lazily. You get consistency without ever blocking a write.
API
createSideEffect#
Creates a lifecycle-managed side effect over one or more signals.
The argument list is variadic: any number of
ReadonlySignals followed by the callback, and the
callback's parameters are inferred as the matching tuple.
function createSideEffect<T extends unknown[]>(
...args: [
...{ [K in keyof T]: ReadonlySignal<T[K]> },
(...values: T) => void | Promise<void>,
]
): SideEffect;
Async callbacks are supported — return a promise and fire off whatever you need. A three-signal effect that re-queries an API whenever any input changes:
const searchEffect = createSideEffect(
userId$,
filters$,
page$,
async (userId, filters, page) => {
// userId: string, filters: Filters, page: number — all inferred
const results = await api.search(userId, filters, page);
results$.value = results;
},
);
searchEffect.run(); // query now, then on every change of any input
searchEffect.stop();
Every listed signal is read synchronously on each emission, which both establishes the subscriptions and guarantees the tuple is consistent (see microtask semantics).
Run options#
Options live on run(), not on
createSideEffect — the same effect can be started
either way on different runs.
| Option | Default | Meaning |
|---|---|---|
immediate |
true |
When true, the callback fires once right
after run() (microtask-deferred) with the
current signal values. When false, the first
emission is swallowed and the callback only fires on
subsequent changes.
|
const persistDraft = createSideEffect(draft$, (draft) => {
localStorage.setItem("draft", JSON.stringify(draft));
});
persistDraft.run(); // fires once now, then on every change
persistDraft.stop();
persistDraft.run({ immediate: false }); // only fires on the NEXT change
immediate: true is the right default for "sync this
somewhere" effects — persistence, mirroring, logging — where the
current value matters as much as future ones. Use
immediate: false for effects that should only react
to changes. Either way, subscriptions are established
immediately: every signal is still read on the first emission,
only the callback is skipped.
createSideEffectsRunner#
Groups side effects so they can be started and stopped together — typically one runner per feature, run on feature mount and stopped on teardown:
import { createSideEffectsRunner } from "@dmytromykhailiuk/preact-signal-effects";
export const imageSideEffects = createSideEffectsRunner();
imageSideEffects.register(uploadEffect, retryEffect, persistEffect);
imageSideEffects.run(); // start everything (idempotent)
imageSideEffects.stop(); // stop everything
imageSideEffects.unregister(retryEffect); // stop + remove one effect
-
run(options?)forwardsRunOptionsto every registered effect and is idempotent: already-running effects are untouched, not-yet-running ones are started. -
stop()stops every registered effect (cancelling their pending invocations, as always). -
register(...effects)adds effects without starting them. Registering while the runner is "running" does not auto-start the new effect — callrun()again to start any newly registered effects. -
unregister(...effects)stops the given effects and removes them from the registry.
A natural shape: each feature module exports its runner with
effects pre-registered; the feature's mount code calls
run() and its teardown calls stop().
Because both are idempotent, remounting is always safe.
Action-driven stores
createEffectsMiddleware#
The bridge between an action-driven store and signal effects: a
classic curried redux middleware that publishes every dispatched
plain action into an actions$ signal. Each call
creates its own local actions$ — no
global mutable action stream, no cross-store leakage; two stores
get two independent streams.
import { createEffectsMiddleware } from "@dmytromykhailiuk/preact-signal-effects";
import { createSignalStore } from "@dmytromykhailiuk/preact-signal-redux";
const { middleware, actions$ } = createEffectsMiddleware<State>();
const store$ = createSignalStore(reducer, initialState, {
middlewares: [thunk, logger, middleware], // recommended: LAST in the chain
});
// actions$ is a ReadonlySignal<DispatchedAction | null> — feed it to
// createSideEffect or (better) createActionEffect.
Because middleware is a plain curried
(api) => (next) => (action) => result
function typed structurally, the exact same value also drops into
redux-toolkit's configureStore — see
using with plain redux / RTK.
Ordering guarantees#
-
The action is published after
next(action)returns — i.e. after the reducer has run. Combined with the microtask deferral ofcreateSideEffect, effect handlers always observe post-reducer state viastore.peek()/getState(). -
The original action is published, not
next's return value — outer middleware may transform results, the stream still carries what was dispatched. -
Non-plain actions (thunk functions, promises —
anything without a string
type) pass through unpublished. - Place it last in the middleware chain so it only sees actions that survived the outer middleware (thunks already unwrapped, filtered actions already dropped).
const doneEffect = createActionEffect(actions$, uploadSucceeded, (action) => {
// The reducer already ran — peek() observes post-reducer state:
const item = store$.peek().items[action.payload.key];
console.log(item.status); // "uploaded"
});
Signals skip identity-equal writes, so dispatching the
same action object twice in a row will not re-emit on
actions$. Action creators produce a fresh object on
every call, so this never bites in practice — it only matters if
you cache and re-dispatch a literal action instance.
createActionEffect#
You could subscribe to actions$ with a raw
createSideEffect — but then every handler starts with
manual type filtering and an unsafe cast:
// Before — filter and cast by hand in every effect:
const uploadEffect = createSideEffect(actions$, async (action) => {
if (action?.type !== tryUploadImage.type) return;
const { key, blob } = (action as ReturnType<typeof tryUploadImage>).payload;
await api.upload(key, blob);
});
createActionEffect replaces that boilerplate with
typed filtering — pass the creator(s) to match, and the handler
receives the fully typed action:
import { createActionEffect, createSideEffectsRunner } from "@dmytromykhailiuk/preact-signal-effects";
// Single creator — payload fully typed from the creator:
const uploadEffect = createActionEffect(actions$, tryUploadImage, async (action) => {
const { key, blob } = action.payload; // typed — no cast, no guard
try {
await api.upload(key, blob);
store$.dispatch(imageUploaded({ key }));
} catch {
store$.dispatch(imageUploadFailed({ key, blob }));
}
});
// Several creators — the action is a typed union:
const persistEffect = createActionEffect(
actions$,
[imageUploaded, imageUploadFailed],
async (action) => {
const image = store$.peek().images[action.payload.key];
if (image) await idb.put(image);
},
);
const runner = createSideEffectsRunner();
runner.register(uploadEffect, persistEffect);
runner.run();
-
Matching uses
creator.match(action)when available (preact-signal-redux and redux-toolkit creators both have it), falling back tocreator.type === action.type— so bare{ type: "..." }objects work too. -
The handler's
actionparameter is inferred from the creator's call signature; an array of creators produces a union of their action types. -
The result is a regular
SideEffect— register it in a runner,run/stopit like any other, same microtask semantics. -
The initial
nullvalue ofactions$(before the first dispatch) never triggers a handler.
Recipes
Using with preact-signal-redux#
The full action-driven pipeline: the store reduces state, the
middleware publishes actions, and a chain of
createActionEffects does the real-world work —
including a retry loop with backoff. (The package's playground
runs a live version of exactly this.)
import {
createAction,
createDevToolsMiddleware,
createSignalStore,
} from "@dmytromykhailiuk/preact-signal-redux";
import {
createActionEffect,
createEffectsMiddleware,
createSideEffectsRunner,
} from "@dmytromykhailiuk/preact-signal-effects";
const addItem = createAction<{ key: string }>("[UPLOADS] add");
const tryUpload = createAction<{ key: string }>("[UPLOADS] try");
const uploadSucceeded = createAction<{ key: string }>("[UPLOADS] succeeded");
const uploadFailed = createAction<{ key: string }>("[UPLOADS] failed");
const { middleware, actions$ } = createEffectsMiddleware<UploadState>();
const store$ = createSignalStore<UploadState>(reducer, { items: {} }, {
// DevTools first, effects middleware LAST — it publishes into actions$:
middlewares: [createDevToolsMiddleware({ name: "uploads" }), middleware],
});
// addItem → kick off the first attempt:
const startEffect = createActionEffect(actions$, addItem, (action) => {
store$.dispatch(tryUpload({ key: action.payload.key }));
});
// tryUpload → do the work, report the outcome:
const uploadEffect = createActionEffect(actions$, tryUpload, async (action) => {
const { key } = action.payload;
try {
await api.upload(key);
store$.dispatch(uploadSucceeded({ key }));
} catch {
store$.dispatch(uploadFailed({ key }));
}
});
// uploadFailed → retry with a delay, max 3 attempts:
const retryEffect = createActionEffect(actions$, uploadFailed, async (action) => {
const { key } = action.payload;
const item = store$.peek().items[key]; // post-reducer state
if (!item || item.attempts >= 3) return;
await delay(1000);
if (!store$.peek().items[key]) return; // cleared meanwhile
store$.dispatch(tryUpload({ key }));
});
const runner = createSideEffectsRunner();
runner.register(startEffect, uploadEffect, retryEffect);
runner.run();
// Later: store$.dispatch(addItem({ key: "photo.png" })) and the pipeline
// uploads, fails, retries and settles — all observable in Redux DevTools.
Redux DevTools time travel performs a silent state write via
the store's replaceState, bypassing the middleware
chain — the effects middleware never republishes during
scrubbing, so effects do not re-fire while you
step through history. That is the behavior you want: replaying
the past must
not replay its side effects (no duplicate uploads, no repeated
API calls).
Using with plain redux / RTK#
createEffectsMiddleware().middleware is a classic
curried middleware, typed structurally — no imports from redux
required on either side. It concatenates straight onto
redux-toolkit's default middleware:
import { configureStore } from "@reduxjs/toolkit";
import { createEffectsMiddleware, createActionEffect } from "@dmytromykhailiuk/preact-signal-effects";
const { middleware, actions$ } = createEffectsMiddleware<RootState>();
const store = configureStore({
reducer,
middleware: (getDefault) => getDefault().concat(middleware),
});
// RTK createAction / createSlice creators have .match — createActionEffect uses it:
const effect = createActionEffect(actions$, todoAdded, (action) => {
console.log("todo added:", action.payload); // payload typed by the RTK creator
});
effect.run();
The structural contract is the whole story — anything that can accept this shape can host the middleware:
type CompatibleMiddleware<S = any> = (api: {
getState(): S;
dispatch(action: any): any;
}) => (next: (action: any) => any) => (action: any) => any;
TypeScript#
Everything is inferred end-to-end: signal tuples
(createSideEffect(a$, b$, (a, b) => ...) types
a and b from the signals), action
payloads from a creator's call signature, and unions across an
array of creators in createActionEffect. The package
ships .d.ts for ESM and .d.cts for CJS.
Requires TypeScript 5+.
import type {
ActionCreatorLike, ActionLike, CompatibleMiddleware, DispatchedAction,
EffectsMiddleware, RunOptions, SideEffect, SideEffectsRunner,
} from "@dmytromykhailiuk/preact-signal-effects";
Exports#
Functions
createSideEffect · createSideEffectsRunner ·
createEffectsMiddleware · createActionEffect
Types
| Type | Meaning |
|---|---|
SideEffect |
A lifecycle-managed effect:
run(options?) / stop() /
isRunning.
|
RunOptions |
Options accepted by run() —
{ immediate?: boolean }.
|
SideEffectsRunner |
A registry that runs / stops a group of effects together:
register / unregister /
run / stop.
|
ActionLike |
The minimal shape of an action: anything with a string
type.
|
DispatchedAction |
An action as observed by the effects middleware — its
payload is unknown to the type system.
|
ActionCreatorLike |
The structural creator shape understood by
createActionEffect: a type,
optionally a match guard and/or a call
signature producing the action.
|
CompatibleMiddleware<S> |
A classic curried redux middleware, typed structurally — assignable wherever redux / RTK / preact-signal-redux middleware is expected. |
EffectsMiddleware<S> |
The result of createEffectsMiddleware:
{ middleware, actions$ }.
|