preact-signal-effects
preact-signal-effects v0.1.0

Lifecycle-managed side effects for Preact signals.

Contents

Contents

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 local actions$ signal.
  • createActionEffect(actions$, creators, handler) — effects filtered by action creator(s), with fully typed payloads.
lifecycle-managed microtask-deferred batch-aware typed action effects no globals framework-agnostic

Getting started

Install#

sh
npm install @dmytromykhailiuk/preact-signal-effects @preact/signals-core
Peer requirements

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.

ts
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:

ts
interface SideEffect {
  run(options?: RunOptions): () => void; // idempotent; returns stop
  stop(): void;                          // unsubscribes + cancels pending invocations
  readonly isRunning: boolean;           // whether currently subscribed
}
  1. run() is idempotent. Calling it while the effect is already running is a no-op that simply returns the stop function again.
  2. 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.
  3. run() / stop() cycle freely. An effect can be started and stopped any number of times, in any order.
Restartable by construction

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

ts
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:

ts
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.
ts
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:

ts
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?) forwards RunOptions to 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 — call run() again to start any newly registered effects.
  • unregister(...effects) stops the given effects and removes them from the registry.
Per-feature lifecycles

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.

ts
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 of createSideEffect, effect handlers always observe post-reducer state via store.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).
ts
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"
});
Identity-equal writes

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:

ts
// 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:

ts
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 to creator.type === action.type — so bare { type: "..." } objects work too.
  • The handler's action parameter 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 / stop it like any other, same microtask semantics.
  • The initial null value of actions$ (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.)

ts
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.
DevTools time travel

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:

ts
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:

ts
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+.

ts
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$ }.