preact-signal-redux
preact-signal-redux v0.1.0

Redux-style state on Preact signals. Zero re-render.

Contents

Contents

preact-signal-redux

Redux-style state management built entirely on Preact signals — typed actions, builder-based reducers and a store that is a ReadonlySignal, with classic redux-compatible middleware and full Redux DevTools time travel.

Redux gives you predictable state transitions, an inspectable action log and a huge middleware ecosystem. Signals give you fine-grained, zero re-render UI updates. This package glues the two: state transitions stay pure and action-driven, while every consumer reads the store as a plain signal — no useSelector, no connect, no re-renders.

zero re-render store is a signal redux-compatible middleware DevTools time travel framework-agnostic typed end to end

Getting started

Install#

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

The only peer dependency is @preact/signals-core. In a Preact app you will normally have @preact/signals installed — it re-exports the same signal primitives from @preact/signals-core, so stores created by this package bind directly in Preact JSX. Outside Preact (vanilla TS, workers, tests) the package works with @preact/signals-core alone.

Quick start#

Declare actions, build a reducer, create a store — then bind computed projections of the store straight into JSX.

tsx
import { computed } from "@preact/signals";
import {
  createAction,
  createDevToolsMiddleware,
  createReducer,
  createSignalStore,
} from "@dmytromykhailiuk/preact-signal-redux";

interface CounterState {
  count: number;
}

const increment = createAction("[COUNTER] increment");
const addAmount = createAction<number>("[COUNTER] addAmount");

const counterStore$ = createSignalStore<CounterState>(
  createReducer((builder) =>
    builder
      .addCase(increment, (state) => ({ count: state.count + 1 }))
      .addCase(addAmount, (state, action) => ({ count: state.count + action.payload })),
  ),
  { count: 0 },
  { middlewares: [createDevToolsMiddleware({ name: "counter" })] },
);

// The store IS a signal — project it with computed, never unwrap .value in render:
const count$ = computed(() => counterStore$.value.count);

function Counter() {
  return (
    <button onClick={() => counterStore$.dispatch(increment())}>
      Count: {count$}
    </button>
  );
}

Dispatch an action → the reducer produces the next state → the signal updates → every bound DOM node updates in place. Counter mounts once and never re-renders: count$ is bound directly to the text node, and the only .value reads happen inside the computed callback and the click handler.

The signal rules#

The store is the reactive primitive — there is no separate subscription API to learn, but the usual signal discipline applies:

  1. The store is a ReadonlySignal<S>. Read store.value inside computed / effect / other reactive contexts to subscribe; bind the resulting signals directly to JSX text and attributes.
  2. getState() (and peek()) are non-reactive. Use them in event handlers, middleware and effects when you need the current state without subscribing — exactly like redux's store.getState().
  3. There is no subscribe(). Signals already are the subscription primitive — use effect():
ts
import { effect } from "@preact/signals-core";

const dispose = effect(() => {
  console.log("state changed:", counterStore$.value);
});
Never unwrap in render

Reading store.value (or any signal's .value) in a component body subscribes the whole component and re-renders it on every change — exactly what this package exists to avoid. Bind the store, or computed projections of it, directly as JSX children and attributes; unwrap .value only inside computed/effect callbacks and event handlers.

Reference

createAction#

Creates a typed action creator. An action is always { type, payload }; the payload type is whatever you pass as the generic argument.

ts
import { createAction } from "@dmytromykhailiuk/preact-signal-redux";

const initialization = createAction("[APP] initialization");   // ActionCreator<void>
const addTodo = createAction<{ text: string }>("[TODOS] add"); // ActionCreator<{ text: string }>

addTodo({ text: "hi" }); // { type: "[TODOS] add", payload: { text: "hi" } }
addTodo.type;            // "[TODOS] add"
`${addTodo}`;            // "[TODOS] add" — toString() returns the type
  • Void creators take no arguments; payload creators require exactly one argument of the payload type, fully typed.
  • .type exposes the action type; toString() returns it too, so creators can be used as map keys and in template strings.
  • .match(action) is the RTK-style type guard: it narrows an unknown action to Action<P> — handy in custom middleware and effects.
ts
const trackTodos: Middleware<State> = () => (next) => (action) => {
  if (addTodo.match(action)) {
    // action is narrowed to Action<{ text: string }>
    analytics.track("todo_added", action.payload.text);
  }
  return next(action);
};

createReducer#

Builder-based, fully inferred case reducers — the action parameter of every case is typed from the creators you register it for.

ts
const reducer = createReducer<State>((builder) =>
  builder
    .addCase(addTodo, (state, action) => ({
      ...state,
      todos: [...state.todos, action.payload],       // payload is typed
    }))
    // several creators may share one case — the action is a typed union:
    .addCase(imageUploaded, imageUploadFailed, (state, action) => ({
      ...state,
      status: imageUploaded.match(action) ? "uploaded" : "error",
    }))
    // runs only when no case matched:
    .addDefaultCase((state, action) => state),
);
  • Multiple creators per case — pass any number of creators before the case reducer; the action parameter is the union of their action types. Use creator.match inside the case to tell them apart.
  • Sequential same-type cases — if several addCase registrations target the same type, they run in registration order, each receiving the previous one's result.
  • Unknown actions return the state unchanged (same reference), so signal subscribers do not fire.
  • addDefaultCase registers a reducer that runs only when no addCase matched the action.
No addMatcher

addMatcher (predicate-based cases) is not included yet. If you need it today, use a default case plus creator.match to branch on the actions you care about.

createSignalStore#

createSignalStore(reducer, initialState, options?) creates a redux-style store whose state lives in a Preact signal and returns a SignalStore<S>.

ts
const store$ = createSignalStore(reducer, initialState, {
  // classic redux middleware, leftmost outermost — devtools is a middleware too:
  middlewares: [thunk, logger, createDevToolsMiddleware({ name: "my-store" })],
  modifyInitialState: (state) => rehydrate(state),  // transform initial state once
  afterUpdate: ({ action, prevState, newState }) => {},  // called after every reduced dispatch
});
Option Meaning
middlewares Middleware chain, applied left-to-right around the reducer (leftmost is outermost), exactly like redux's applyMiddleware. See Middleware.
modifyInitialState Transforms the initial state once, before the store is created — useful for rehydrating persisted state.
afterUpdate Invoked after each dispatched action has been reduced and written to the signal, with { action, prevState, newState }.
afterUpdate and time travel

afterUpdate is not invoked on DevTools time travel — time travel is a silent state write, not a dispatch.

Store API#

The returned store extends ReadonlySignal<S> — everything a signal can do, plus two redux-style members:

Member Meaning
store$.value Reactive read — subscribe from computed / effect; bind projections in JSX.
store$.getState() / store$.peek() Non-reactive read of the current state (getState() is an alias of peek()).
store$.dispatch(action) Runs the middleware chain + reducer; returns the action (redux semantics).

Integration

Middleware#

Middleware uses the classic curried redux signature (api) => (next) => (action) and is structurally compatible with the redux ecosystem:

ts
import type { Middleware } from "@dmytromykhailiuk/preact-signal-redux";

const logger: Middleware<State> = ({ getState, dispatch }) => (next) => (action) => {
  console.log("dispatching", action.type, "state before:", getState());
  const result = next(action);           // pass along — or don't, to swallow the action
  console.log("state after:", getState());
  return result;
};

What middleware can do — identical to redux:

  • Pass the action on with next(action); the innermost next is the reducer step.
  • Swallow the action by not calling next.
  • Transform it — call next(otherAction).
  • Dispatch more actions via api.dispatch(...) — this re-enters the full chain from the top.
  • Read state via api.getState() — before next it is the pre-action state, after next the post-action state.

Redux ecosystem compatibility#

Middleware, MiddlewareAPI and Dispatch are structural — no imports from redux required — so redux-thunk-style and redux-logger-style middleware work as-is. The inner action is intentionally any: that is exactly what makes function-dispatching middleware like thunk assignable. Here is a complete, realistic thunk setup — async data fetching with request de-duplication:

ts
import type { Dispatch, Middleware } from "@dmytromykhailiuk/preact-signal-redux";

// The classic thunk middleware — identical to redux-thunk's core:
type Thunk<S, R = void> = (dispatch: Dispatch, getState: () => S) => R;

const thunk: Middleware<State> = (api) => (next) => (action) =>
  typeof action === "function" ? action(api.dispatch, api.getState) : next(action);

// A typed helper so thunks dispatch without casts:
const store$ = createSignalStore(reducer, initialState, { middlewares: [thunk] });
const dispatchThunk = <R,>(t: Thunk<State, R>): R => (store$.dispatch as any)(t);

// Actions for the request lifecycle:
const userRequested = createAction<{ id: string }>("[USERS] requested");
const userLoaded = createAction<{ id: string; user: User }>("[USERS] loaded");
const userFailed = createAction<{ id: string; message: string }>("[USERS] failed");

// The thunk itself — reads state, awaits IO, dispatches results:
const fetchUser =
  (id: string): Thunk<State, Promise<void>> =>
  async (dispatch, getState) => {
    if (getState().users[id]) return;           // already cached — skip the request
    dispatch(userRequested({ id }));
    try {
      const response = await fetch(`/api/users/${id}`);
      if (!response.ok) throw new Error(response.statusText);
      dispatch(userLoaded({ id, user: await response.json() }));
    } catch (error) {
      dispatch(userFailed({ id, message: String(error) }));
    }
  };

await dispatchThunk(fetchUser("42"));
store$.getState().users["42"]; // loaded (or an error recorded by userFailed)

Every nested dispatch inside the thunk re-enters the full middleware chain, so loggers, the DevTools middleware and effects middleware all observe the lifecycle actions.

The same goes for logger-shaped middleware — the logger above is exactly the shape redux-logger uses, and error-boundary middleware ports unchanged:

ts
// A crash reporter in the exact shape the redux docs use:
const crashReporter: Middleware<State> = (api) => (next) => (action) => {
  try {
    return next(action);
  } catch (err) {
    console.error("Caught an exception!", err, "state:", api.getState());
    throw err;
  }
};

Dispatch rules#

  • dispatch returns the action it was given, mirroring redux — store$.dispatch(increment()) evaluates to the increment() action.
  • api.dispatch re-enters the full chain from the top. It is late-bound, so every middleware receives the final composed dispatch — dispatching from inside middleware behaves exactly like an external store$.dispatch call.

Two rules are enforced at runtime, for redux parity:

Dispatches that throw

Reducers may not dispatch. Calling dispatch while the reducer is running throws "Reducers may not dispatch actions."

No dispatching during chain construction. Calling api.dispatch from a middleware's outer (api) => ... body — while the chain is still being composed — throws "Dispatching while constructing your middleware is not allowed."

Tooling

Redux DevTools#

DevTools support is a middlewarecreateDevToolsMiddleware(options?) returns a regular Middleware that you register like any other, one per store. Every middleware instance connects as its own instance in the DevTools instance selector.

ts
import { createDevToolsMiddleware } from "@dmytromykhailiuk/preact-signal-redux";

const store$ = createSignalStore(reducer, initialState, {
  middlewares: [
    thunk,
    logger,
    createDevToolsMiddleware({ name: "app/todos", maxAge: 100, trace: true }),
  ],
});
  • enabled — set enabled: false and the middleware stays a transparent pass-through even though it is registered; no DevTools connection is made. Perfect for production builds:
ts
createDevToolsMiddleware({ name: "app/todos", enabled: import.meta.env.DEV })
  • name sets the instance name shown in DevTools. Omit it and a unique one is auto-generated: signal-store-1, signal-store-2, … All other option fields (maxAge, latency, trace, anything the extension understands) are passed straight to the extension's connect() call.
  • Placement — put it near the end of the chain: it records the action + the post-reducer state after next(action) returns, so actions swallowed by outer middleware never appear in the log.
  • When the extension is missing (or during SSR, where window is undefined) the middleware is a pass-through — zero overhead, no errors.

Time travel#

The integration is bidirectional: dispatches are recorded (send), and DevTools commands are applied back to the store.

DevTools command Effect on the store
Jump to action / state State is written silently; signal subscribers (and the UI) update.
Rollback State restored to the last committed baseline.
Reset State restored to the initial state.
Commit Current state becomes the new baseline.
Import state Last computed state from the imported session is applied.
Pause recording Dispatches stop being sent until resumed.
Dispatch from the DevTools UI Goes through the real middleware chain.

Semantics worth knowing:

  • Time travel writes state silently via the store-provided api.replaceState — an optional MiddlewareAPI member that createSignalStore always supplies. It is a wholesale state write that bypasses the reducer, the middleware chain and afterUpdate, and does not re-trigger side effects (an attached effects middleware never republishes during time travel). Signal subscribers still react — the UI follows the jump. This is deliberate: replaying history must not replay its side effects.
  • Actions are recorded after next() returns — the middleware sends the action together with the post-reducer state, so an action swallowed by outer middleware never appears in the DevTools log.
  • On a bare redux-shaped store (no replaceState on the middleware API) the middleware still records dispatches, but time-travel jumps are no-ops.
Keep state JSON-serialisable

Time travel serialises state through JSON. If you use DevTools, keep store state JSON-serialisable — functions, class instances, Map/Set and the like will not survive the round trip.

Multiple stores#

There is no global enhancer — DevTools is a per-store middleware. Create one createDevToolsMiddleware instance per store with a distinct name and you get two independent instances in the DevTools selector, each with its own action log and its own time travel:

ts
const counter$ = createSignalStore(counterReducer, counterInitial, {
  middlewares: [createDevToolsMiddleware({ name: "app/counter" })],
});

const todos$ = createSignalStore(todosReducer, todosInitial, {
  middlewares: [
    logger,
    createDevToolsMiddleware({ name: "app/todos", maxAge: 100, trace: true }),
  ],
});

Jumping around app/counter's history never touches app/todos — the stores stay fully independent. Compose cross-store views with computed instead of a combineReducers-style root store.

TypeScript#

Everything is inferred end-to-end: creator payloads (createAction<P>), case reducer actions (including unions for multi-creator cases), dispatch return types and store state. The package ships .d.ts for ESM and .d.cts for CJS. Requires TypeScript 5+.

ts
import type {
  Action, AnyAction, ActionCreator, CaseReducer, Reducer,
  Dispatch, Middleware, MiddlewareAPI,
  SignalStore, CreateSignalStoreOptions, DevToolsMiddlewareOptions,
  ActionReducerMapBuilder,
} from "@dmytromykhailiuk/preact-signal-redux";

Exports#

Functions

createAction · createReducer · createSignalStore · createDevToolsMiddleware

Types

Type Meaning
Action<P> A dispatched action: a type string plus a typed payload.
AnyAction An action whose payload is unknown to the type system.
ActionCreator<P> A callable creator with type, toString() and the match type guard.
CaseReducer<S, A> A reducer for one specific case (typed action).
Reducer<S> A root reducer: pure (state, action) => state.
ActionReducerMapBuilder<S> The builder passed to the createReducer callback (addCase / addDefaultCase).
Dispatch Dispatch that returns the dispatched action, mirroring redux.
Middleware<S> Classic curried middleware: (api) => (next) => (action) => result.
MiddlewareAPI<S> The { getState, dispatch, replaceState? } surface handed to middleware — a structural superset of redux's, so ecosystem middleware fits unchanged.
SignalStore<S> The store: ReadonlySignal<S> plus dispatch / getState.
CreateSignalStoreOptions<S> The options bag: middlewares, modifyInitialState, afterUpdate.
DevToolsMiddlewareOptions Options for createDevToolsMiddleware: name, enabled, plus anything the extension's connect() accepts.