@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.
Maintainers
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 (orcomputedprojections 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 anenabledswitch 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-coreThe 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
- The store is a
ReadonlySignal<S>. Readstore.valueinsidecomputed/effect/other reactive contexts to subscribe; bind the resulting signals directly to JSX. getState()(andpeek()) are non-reactive. Use them in event handlers, middleware and effects when you need the current state without subscribing — exactly like redux'sstore.getState().- 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.
matchis 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
addCaseregistrations 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.matchif 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 likeapplyMiddleware. 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 innermostnextis 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()— beforenextit is the pre-action state, afternextthe 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— setenabled: falseand 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'sconnect().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 callafterUpdate, 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
