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.
Getting started
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#
Declare actions, build a reducer, create a store — then bind
computed projections of the store straight into JSX.
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:
-
The store is a
ReadonlySignal<S>. Readstore.valueinsidecomputed/effect/ other reactive contexts to subscribe; bind the resulting signals directly to JSX text and attributes. -
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 — useeffect():
import { effect } from "@preact/signals-core";
const dispose = effect(() => {
console.log("state changed:", counterStore$.value);
});
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.
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.
-
.typeexposes 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 toAction<P>— handy in custom middleware and effects.
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.
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.matchinside the case to tell them apart. -
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.
-
addDefaultCaseregisters a reducer that runs only when noaddCasematched the action.
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>.
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 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:
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.
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:
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:
// 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#
-
dispatchreturns the action it was given, mirroring redux —store$.dispatch(increment())evaluates to theincrement()action. -
api.dispatchre-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 externalstore$.dispatchcall.
Two rules are enforced at runtime, for redux parity:
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 middleware —
createDevToolsMiddleware(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.
import { createDevToolsMiddleware } from "@dmytromykhailiuk/preact-signal-redux";
const store$ = createSignalStore(reducer, initialState, {
middlewares: [
thunk,
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 })
-
namesets 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'sconnect()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
windowis 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 optionalMiddlewareAPImember thatcreateSignalStorealways supplies. It is a wholesale state write that bypasses the reducer, the middleware chain andafterUpdate, 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
replaceStateon the middleware API) the middleware still records dispatches, but time-travel jumps are no-ops.
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:
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+.
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.
|