@dmytromykhailiuk/preact-signal-effects
v0.1.0
Published
Signal side effects for @preact/signals — declarative createSideEffect over any signals, a lifecycle-managed runner, and a redux-compatible effects middleware with typed createActionEffect. Standalone, framework-agnostic.
Maintainers
Readme
@dmytromykhailiuk/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 @dmytromykhailiuk/preact-signal-redux, plain redux / redux-toolkit, or no store at all.
Docs: the full documentation lives in
index.html— this README is the short form.
Why this exists. 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.
Install
npm install @dmytromykhailiuk/preact-signal-effects @preact/signals-coreThe only peer dependency is @preact/signals-core (the primitives @preact/signals itself is built on). No dependency on any store library — redux compatibility is purely structural.
Quick start
A side effect over plain signals — no store involved:
import { signal } from "@preact/signals";
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
// ...
stop(); // or persistPrefs.stop()Concepts
Lifecycle
createSideEffect returns a SideEffect: { run(options?), stop(), isRunning }.
run()subscribes and returns thestopfunction. Callingrun()while running is a no-op (returnsstop).stop()unsubscribes and cancels invocations that are scheduled but not yet flushed.run()/stop()can cycle any number of times.
Microtask deferral & batching
Signal values are captured synchronously (so the tuple is always consistent), but your callback runs in a microtask:
- Writes inside
batch()collapse into a single invocation. - N separate synchronous writes produce N invocations, each with the values captured at write time.
- Your callback never runs in the middle of a signal write.
First emission
By default run() fires the callback once immediately (microtask-deferred) with the current values — the right default for "sync this somewhere" effects. Pass run({ immediate: false }) to skip the first emission and only react to subsequent changes (subscriptions are still established by reading every signal).
createSideEffectsRunner
Group effects and manage them together:
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 effectrun(options?)forwardsRunOptionsto every effect and only starts effects that are not already running.- Registering while "running" does not auto-start the new effect — call
run()again.
createEffectsMiddleware
The bridge between an action-driven store and signal effects. Each call creates its own local actions$ — no global state:
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
});Ordering guarantees:
- The action is published after
next(action)returns — i.e. after the reducer ran. Combined with the microtask deferral, effect handlers always observe post-reducer state viastore.peek()/getState(). - The original action is published, not
next's return value. - Non-plain actions (thunk functions, promises) pass through unpublished.
- Placed last, it only sees actions that survived the outer middleware.
One caveat: signals skip identity-equal writes, so dispatching the same action object twice will not re-emit. Action creators produce fresh objects on every call, so this never bites in practice.
createActionEffect
Replaces the manual if (action?.type !== someAction.type) return; boilerplate with typed filtering:
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;
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— bare{ type: "..." }objects work too. - The handler's
actionparameter is inferred from the creator's call signature (union across an array of creators). - The result is a regular
SideEffect— register it in a runner,run/stopit like any other. - The initial
nullvalue ofactions$never triggers a handler.
Using with preact-signal-redux
The full action-driven pipeline (see the playground for a live version):
const { middleware, actions$ } = createEffectsMiddleware<UploadState>();
const store$ = createSignalStore(reducer, { items: {} }, {
middlewares: [createDevToolsMiddleware({ name: "uploads" }), middleware],
});
const uploadEffect = createActionEffect(actions$, tryUpload, async (action) => {
try {
await fakeUpload(action.payload.key);
store$.dispatch(uploadSucceeded({ key: action.payload.key }));
} catch {
store$.dispatch(uploadFailed({ key: action.payload.key }));
}
});
const retryEffect = createActionEffect(actions$, uploadFailed, async (action) => {
await delay(1000);
if (store$.peek().items[action.payload.key]) {
store$.dispatch(tryUpload({ key: action.payload.key }));
}
});
const runner = createSideEffectsRunner();
runner.register(uploadEffect, retryEffect);
runner.run();Note: Redux DevTools time travel on the store performs silent state writes that bypass middleware — effects do not re-fire while you scrub through history. Replaying the past must not replay its side effects.
Using with plain redux / redux-toolkit
createEffectsMiddleware().middleware is a classic curried middleware, typed structurally — no imports from redux required:
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 creators have .match — createActionEffect uses it:
const effect = createActionEffect(actions$, todoAdded, (action) => {
console.log("todo added:", action.payload);
});
effect.run();TypeScript
Signal tuples (createSideEffect(a$, b$, (a, b) => ...)), action payloads and creator unions are inferred end-to-end. 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";License
MIT © Dmytro Mykhailiuk
