npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@dmytromykhailiuk/preact-signal-redux

v0.1.0

Published

Redux-style state management on Preact signals — createAction, createReducer and createSignalStore with classic redux-compatible middleware and full Redux DevTools time travel. Framework-agnostic, zero re-render.

Readme

@dmytromykhailiuk/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. Framework-agnostic, zero re-render.

Docs: the full documentation lives in index.html — this README is the short form.

Why this exists. 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.

  • The store extends ReadonlySignal<S> — bind it (or computed projections of it) directly in JSX.
  • Middleware uses the classic curried redux signature (api) => (next) => (action) — redux-thunk / redux-logger style middleware plugs in unchanged.
  • Redux DevTools support is itself a middleware (createDevToolsMiddleware): each store connects under its own instance name, with full time travel (jump, rollback, reset, commit) and an enabled switch for production builds.
  • No global mutable state: actions are delivered to whoever needs them (e.g. @dmytromykhailiuk/preact-signal-effects) through middleware, not through a shared global signal.

Install

npm install @dmytromykhailiuk/preact-signal-redux @preact/signals-core

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

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 — bind it directly, 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. No component re-renders.

The signal rules

  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.
  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:
import { effect } from "@preact/signals-core";

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

createAction

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

addTodo({ text: "hi" });  // { type: "[TODOS] add", payload: { text: "hi" } }
addTodo.type;             // "[TODOS] add"
`${addTodo}`;             // "[TODOS] add" — toString() returns the type
addTodo.match(action);    // type guard: narrows action to Action<{ text: string }>
  • Void creators take no arguments; payload creators require exactly one, fully typed.
  • match is the RTK-style type guard — handy in custom middleware and effects.

createReducer

Builder-based, fully inferred case reducers:

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

Semantics:

  • Multiple creators per case — pass any number of creators before the case reducer; the action parameter is the union of their action types.
  • 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.
  • addMatcher (predicate-based cases) is not included yet; use a default case + creator.match if you need it today.

createSignalStore

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

| 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 | | store$.dispatch(action) | Runs the middleware chain + reducer; returns the action (redux semantics) |

Options:

  • middlewares — applied left-to-right around the reducer, exactly like applyMiddleware. See below.
  • modifyInitialState — runs 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 }. It is not invoked on DevTools time travel (time travel is a silent state write, not a dispatch).

Middleware

The classic curried redux signature, structurally compatible with the redux ecosystem:

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.

Rules enforced at runtime (redux parity):

  • Reducers may not dispatch — doing so throws "Reducers may not dispatch actions.".
  • Dispatching while the chain is still being constructed (from the middleware's outer (api) => ... body) throws.

Using ecosystem middleware

The types are structural, so redux-thunk-style and redux-logger-style middleware work as-is. Here is a complete, realistic thunk setup — async data fetching with request de-duplication:

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.

Redux DevTools

DevTools support is a middleware — register it like any other, one per store; every store gets its own instance in the DevTools UI:

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

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 }),
  ],
});
  • 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:

    createDevToolsMiddleware({ name: "app/todos", enabled: import.meta.env.DEV })
  • name — omit it to auto-generate a unique name (signal-store-1, signal-store-2, ...). All other option fields are passed straight to the extension's connect().

  • Placement — put it near the end of the chain: it records the action + 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) 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 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: it bypasses the reducer and the middleware chain, does not call afterUpdate, and does not re-trigger side effects (an attached effects middleware never republishes during time travel). This is deliberate — replaying history must not replay its side effects.
  • Time travel serializes state through JSON, so keep store state JSON-serializable if you use DevTools.

Differences from redux / redux-toolkit

| | redux / RTK | preact-signal-redux | | --- | --- | --- | | State container | plain object + subscribe() | ReadonlySignal<S> — signals are the subscription | | Reading state in UI | useSelector (re-renders) | bind computed projections (zero re-render) | | Middleware | (api) => (next) => (action) | identical, structurally compatible | | combineReducers | yes | not needed — compose several stores and derive with computed | | Immer in reducers | RTK: yes | no — reducers must return new state | | DevTools | one global enhancer | a per-store middleware, separate instances |

Compared to the original prototype this package grew out of: middleware is registered at creation (options.middlewares) instead of registerMiddleware, and the store no longer publishes actions into a global actions$ signal — use an effects middleware (e.g. @dmytromykhailiuk/preact-signal-effects) to observe actions.

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

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

License

MIT © Dmytro Mykhailiuk